assertpy2 2.8.0__tar.gz → 2.8.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. {assertpy2-2.8.0 → assertpy2-2.8.1}/PKG-INFO +18 -17
  2. {assertpy2-2.8.0 → assertpy2-2.8.1}/README.md +17 -16
  3. assertpy2-2.8.1/assertpy2/_introspection.py +66 -0
  4. {assertpy2-2.8.0 → assertpy2-2.8.1}/assertpy2/_mixin_base.py +2 -1
  5. {assertpy2-2.8.0 → assertpy2-2.8.1}/assertpy2/assertpy.py +11 -5
  6. {assertpy2-2.8.0 → assertpy2-2.8.1}/assertpy2/async_assertions.py +1 -1
  7. {assertpy2-2.8.0 → assertpy2-2.8.1}/assertpy2/base.py +34 -41
  8. {assertpy2-2.8.0 → assertpy2-2.8.1}/assertpy2/behave_matchers.py +14 -9
  9. {assertpy2-2.8.0 → assertpy2-2.8.1}/assertpy2/collection.py +7 -7
  10. {assertpy2-2.8.0 → assertpy2-2.8.1}/assertpy2/contains.py +11 -17
  11. {assertpy2-2.8.0 → assertpy2-2.8.1}/assertpy2/date.py +2 -2
  12. {assertpy2-2.8.0 → assertpy2-2.8.1}/assertpy2/dict.py +8 -10
  13. {assertpy2-2.8.0 → assertpy2-2.8.1}/assertpy2/dynamic.py +6 -5
  14. {assertpy2-2.8.0 → assertpy2-2.8.1}/assertpy2/errors.py +3 -4
  15. {assertpy2-2.8.0 → assertpy2-2.8.1}/assertpy2/exception.py +3 -3
  16. {assertpy2-2.8.0 → assertpy2-2.8.1}/assertpy2/extracting.py +4 -6
  17. {assertpy2-2.8.0 → assertpy2-2.8.1}/assertpy2/file.py +2 -2
  18. {assertpy2-2.8.0 → assertpy2-2.8.1}/assertpy2/helpers.py +12 -12
  19. {assertpy2-2.8.0 → assertpy2-2.8.1}/assertpy2/matchers.py +25 -22
  20. {assertpy2-2.8.0 → assertpy2-2.8.1}/assertpy2/pytest_plugin.py +16 -15
  21. {assertpy2-2.8.0 → assertpy2-2.8.1}/assertpy2/snapshot.py +7 -4
  22. {assertpy2-2.8.0 → assertpy2-2.8.1}/assertpy2/string.py +7 -5
  23. {assertpy2-2.8.0 → assertpy2-2.8.1}/docs/assertions.md +15 -1
  24. assertpy2-2.8.1/docs/assets/diff-equal.png +0 -0
  25. assertpy2-2.8.1/docs/assets/diff-equal.svg +64 -0
  26. assertpy2-2.8.1/docs/assets/diff-match.svg +51 -0
  27. assertpy2-2.8.1/docs/assets/diff-sequence.svg +52 -0
  28. assertpy2-2.8.1/docs/assets/diff-set.svg +48 -0
  29. {assertpy2-2.8.0 → assertpy2-2.8.1}/docs/comparison.md +27 -21
  30. {assertpy2-2.8.0 → assertpy2-2.8.1}/docs/errors.md +21 -18
  31. {assertpy2-2.8.0 → assertpy2-2.8.1}/docs/extending.md +9 -0
  32. {assertpy2-2.8.0 → assertpy2-2.8.1}/docs/getting-started.md +5 -7
  33. {assertpy2-2.8.0 → assertpy2-2.8.1}/docs/index.md +13 -8
  34. {assertpy2-2.8.0 → assertpy2-2.8.1}/docs/matchers.md +15 -6
  35. {assertpy2-2.8.0 → assertpy2-2.8.1}/docs/migration.md +1 -1
  36. assertpy2-2.8.1/docs/type-safety.md +61 -0
  37. {assertpy2-2.8.0 → assertpy2-2.8.1}/mkdocs.yml +1 -0
  38. {assertpy2-2.8.0 → assertpy2-2.8.1}/pyproject.toml +4 -4
  39. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_datetime.py +16 -8
  40. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_dict_compare.py +10 -5
  41. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_expected_exception.py +4 -2
  42. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_pytest_plugin.py +19 -1
  43. {assertpy2-2.8.0 → assertpy2-2.8.1}/uv.lock +1 -1
  44. {assertpy2-2.8.0 → assertpy2-2.8.1}/.codecov.yml +0 -0
  45. {assertpy2-2.8.0 → assertpy2-2.8.1}/.gitattributes +0 -0
  46. {assertpy2-2.8.0 → assertpy2-2.8.1}/.github/dependabot.yml +0 -0
  47. {assertpy2-2.8.0 → assertpy2-2.8.1}/.github/workflows/ci.yml +0 -0
  48. {assertpy2-2.8.0 → assertpy2-2.8.1}/.github/workflows/codeql.yml +0 -0
  49. {assertpy2-2.8.0 → assertpy2-2.8.1}/.github/workflows/docs.yml +0 -0
  50. {assertpy2-2.8.0 → assertpy2-2.8.1}/.github/workflows/publish.yml +0 -0
  51. {assertpy2-2.8.0 → assertpy2-2.8.1}/.github/workflows/scorecard.yml +0 -0
  52. {assertpy2-2.8.0 → assertpy2-2.8.1}/.github/workflows/zizmor.yml +0 -0
  53. {assertpy2-2.8.0 → assertpy2-2.8.1}/.gitignore +0 -0
  54. {assertpy2-2.8.0 → assertpy2-2.8.1}/CONTRIBUTING.md +0 -0
  55. {assertpy2-2.8.0 → assertpy2-2.8.1}/LICENSE +0 -0
  56. {assertpy2-2.8.0 → assertpy2-2.8.1}/SECURITY.md +0 -0
  57. {assertpy2-2.8.0 → assertpy2-2.8.1}/assertpy2/__init__.py +0 -0
  58. {assertpy2-2.8.0 → assertpy2-2.8.1}/assertpy2/_compat.py +0 -0
  59. {assertpy2-2.8.0 → assertpy2-2.8.1}/assertpy2/_typing.py +0 -0
  60. {assertpy2-2.8.0 → assertpy2-2.8.1}/assertpy2/bytes_mixin.py +0 -0
  61. {assertpy2-2.8.0 → assertpy2-2.8.1}/assertpy2/json_mixin.py +0 -0
  62. {assertpy2-2.8.0 → assertpy2-2.8.1}/assertpy2/numeric.py +0 -0
  63. {assertpy2-2.8.0 → assertpy2-2.8.1}/assertpy2/py.typed +0 -0
  64. {assertpy2-2.8.0 → assertpy2-2.8.1}/assertpy2/warning.py +0 -0
  65. {assertpy2-2.8.0 → assertpy2-2.8.1}/docs/data.md +0 -0
  66. {assertpy2-2.8.0 → assertpy2-2.8.1}/docs/fluent.md +0 -0
  67. {assertpy2-2.8.0 → assertpy2-2.8.1}/docs/integrations.md +0 -0
  68. {assertpy2-2.8.0 → assertpy2-2.8.1}/docs/logo-dark.svg +0 -0
  69. {assertpy2-2.8.0 → assertpy2-2.8.1}/docs/logo.svg +0 -0
  70. {assertpy2-2.8.0 → assertpy2-2.8.1}/docs/testing.md +0 -0
  71. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_async.py +0 -0
  72. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_behave_matchers.py +0 -0
  73. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_bool.py +0 -0
  74. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_bytes.py +0 -0
  75. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_class.py +0 -0
  76. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_collection.py +0 -0
  77. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_core.py +0 -0
  78. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_custom_dict.py +0 -0
  79. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_custom_list.py +0 -0
  80. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_description.py +0 -0
  81. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_dict.py +0 -0
  82. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_dyn.py +0 -0
  83. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_equals.py +0 -0
  84. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_errors.py +0 -0
  85. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_expected_warning.py +0 -0
  86. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_extensions.py +0 -0
  87. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_extracting.py +0 -0
  88. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_fail.py +0 -0
  89. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_file.py +0 -0
  90. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_grouped_soft.py +0 -0
  91. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_in.py +0 -0
  92. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_json.py +0 -0
  93. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_list.py +0 -0
  94. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_matcher_registry.py +0 -0
  95. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_matchers.py +0 -0
  96. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_matchers_phase3.py +0 -0
  97. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_namedtuple.py +0 -0
  98. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_none.py +0 -0
  99. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_not.py +0 -0
  100. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_numbers.py +0 -0
  101. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_overloads.py +0 -0
  102. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_phase2.py +0 -0
  103. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_pipeline.py +0 -0
  104. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_property_based.py +0 -0
  105. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_readme.py +0 -0
  106. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_recursive_compare.py +0 -0
  107. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_recursive_compare_attrs.py +0 -0
  108. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_recursive_compare_pydantic.py +0 -0
  109. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_regex_groups.py +0 -0
  110. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_rich_diff.py +0 -0
  111. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_same_as.py +0 -0
  112. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_snapshots.py +0 -0
  113. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_soft.py +0 -0
  114. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_soft_fail.py +0 -0
  115. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_string.py +0 -0
  116. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_structural.py +0 -0
  117. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_traceback.py +0 -0
  118. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_type.py +0 -0
  119. {assertpy2-2.8.0 → assertpy2-2.8.1}/tests/test_warn.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertpy2
3
- Version: 2.8.0
3
+ Version: 2.8.1
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
@@ -66,7 +66,7 @@ Description-Content-Type: text/markdown
66
66
 
67
67
  ---
68
68
 
69
- ## Quick start
69
+ ## [Quick start](https://solganis.github.io/assertpy2/getting-started/)
70
70
 
71
71
  ```bash
72
72
  pip install assertpy2 # drop-in replacement for assertpy, just change the import
@@ -84,10 +84,12 @@ def test_user():
84
84
  assert_that(user).has_name("Alice")
85
85
  ```
86
86
 
87
- ## Why fluent assertions?
87
+ Browse the [full documentation](https://solganis.github.io/assertpy2/) for every assertion, matcher, and integration.
88
+
89
+ ## [Why fluent assertions?](https://solganis.github.io/assertpy2/comparison/)
88
90
 
89
91
  A fluent chain reads as one intent and replaces several bare asserts - and your IDE
90
- offers only the methods that fit the value's type:
92
+ offers only the [methods that fit the value's type](https://solganis.github.io/assertpy2/type-safety/):
91
93
 
92
94
  ```py
93
95
  # bare - three statements, no autocomplete help
@@ -111,25 +113,19 @@ E {'user': {'name': 'Alice', 'role': 'superadmin'}} != {'user': {'name': 'Al
111
113
  E {'status': 'active'} != {'status': 'disabled'}
112
114
  ```
113
115
 
114
- assertpy2 reports the exact path to every difference:
116
+ assertpy2 reports the [exact path to every difference](https://solganis.github.io/assertpy2/errors/#rich-pytest-diffs), in color:
115
117
 
116
- ```text
118
+ ```python
117
119
  assert_that(response).is_equal_to(expected)
118
- --- Structured Diff ---
119
- diff (dict):
120
- status:
121
- - 'active'
122
- + 'disabled'
123
- user.role:
124
- - 'superadmin'
125
- + 'admin'
126
120
  ```
127
121
 
122
+ <img src="docs/assets/diff-equal.png" width="300" alt="Structured diff in the terminal: status and user.role shown with their paths, removals in red and additions in green">
123
+
128
124
  Recursive diffs work for dicts, dataclasses, namedtuples, attrs, and Pydantic models.
129
125
  For responses with dynamic fields (IDs, timestamps), validate a subset with
130
- `matches_structure()` instead of exact equality.
126
+ [`matches_structure()`](https://solganis.github.io/assertpy2/matchers/#structural-matching) instead of exact equality.
131
127
 
132
- ## Type-aware autocomplete
128
+ ## [Type-aware autocomplete](https://solganis.github.io/assertpy2/type-safety/)
133
129
 
134
130
  `assert_that()` uses `@overload` to return type-specific Protocols.
135
131
  Your IDE shows only methods relevant to the value you're testing, not all 100+:
@@ -142,6 +138,8 @@ Your IDE shows only methods relevant to the value you're testing, not all 100+:
142
138
 
143
139
  9 type-specific Protocols instead of one `Any`. Works in PyCharm, VS Code, and any LSP-compatible editor.
144
140
 
141
+ See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) guide for the full walkthrough.
142
+
145
143
  ---
146
144
 
147
145
  ## Features
@@ -170,6 +168,7 @@ Your IDE shows only methods relevant to the value you're testing, not all 100+:
170
168
  - [**Structured errors**](https://solganis.github.io/assertpy2/errors/#structured-errors): `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes.
171
169
  - [**Rich pytest diffs**](https://solganis.github.io/assertpy2/errors/#rich-pytest-diffs): recursive structural diffs for lists, sets, strings, dicts, dataclasses, namedtuples, Pydantic models, and matcher-based assertions (`matches_structure()`, `satisfies()`, `each()`). Circular reference protection.
172
170
  - [**Snapshot testing**](https://solganis.github.io/assertpy2/testing/#snapshot-testing): store and compare data structures in JSON format.
171
+ - **Property-based tested**: comparison, selective-diff, matcher algebra, and collection logic are checked with [Hypothesis](https://hypothesis.readthedocs.io) against reference semantics, on top of 100% branch coverage.
173
172
 
174
173
  **Type safety**
175
174
 
@@ -191,7 +190,7 @@ See the [full documentation](https://solganis.github.io/assertpy2/) for all asse
191
190
 
192
191
  ---
193
192
 
194
- ## Integrations
193
+ ## [Integrations](https://solganis.github.io/assertpy2/integrations/)
195
194
 
196
195
  ### Allure
197
196
 
@@ -239,6 +238,8 @@ def step_impl(context, age):
239
238
 
240
239
  Available types: `PositiveInt`, `NonNegativeInt`, `PositiveFloat`, `NonEmptyString`, `BoolLike`.
241
240
 
241
+ See the [Integrations guide](https://solganis.github.io/assertpy2/integrations/) for attachment modes, configuration, and full examples.
242
+
242
243
  ---
243
244
 
244
245
  <p align="center">
@@ -28,7 +28,7 @@
28
28
 
29
29
  ---
30
30
 
31
- ## Quick start
31
+ ## [Quick start](https://solganis.github.io/assertpy2/getting-started/)
32
32
 
33
33
  ```bash
34
34
  pip install assertpy2 # drop-in replacement for assertpy, just change the import
@@ -46,10 +46,12 @@ def test_user():
46
46
  assert_that(user).has_name("Alice")
47
47
  ```
48
48
 
49
- ## Why fluent assertions?
49
+ Browse the [full documentation](https://solganis.github.io/assertpy2/) for every assertion, matcher, and integration.
50
+
51
+ ## [Why fluent assertions?](https://solganis.github.io/assertpy2/comparison/)
50
52
 
51
53
  A fluent chain reads as one intent and replaces several bare asserts - and your IDE
52
- offers only the methods that fit the value's type:
54
+ offers only the [methods that fit the value's type](https://solganis.github.io/assertpy2/type-safety/):
53
55
 
54
56
  ```py
55
57
  # bare - three statements, no autocomplete help
@@ -73,25 +75,19 @@ E {'user': {'name': 'Alice', 'role': 'superadmin'}} != {'user': {'name': 'Al
73
75
  E {'status': 'active'} != {'status': 'disabled'}
74
76
  ```
75
77
 
76
- assertpy2 reports the exact path to every difference:
78
+ assertpy2 reports the [exact path to every difference](https://solganis.github.io/assertpy2/errors/#rich-pytest-diffs), in color:
77
79
 
78
- ```text
80
+ ```python
79
81
  assert_that(response).is_equal_to(expected)
80
- --- Structured Diff ---
81
- diff (dict):
82
- status:
83
- - 'active'
84
- + 'disabled'
85
- user.role:
86
- - 'superadmin'
87
- + 'admin'
88
82
  ```
89
83
 
84
+ <img src="docs/assets/diff-equal.png" width="300" alt="Structured diff in the terminal: status and user.role shown with their paths, removals in red and additions in green">
85
+
90
86
  Recursive diffs work for dicts, dataclasses, namedtuples, attrs, and Pydantic models.
91
87
  For responses with dynamic fields (IDs, timestamps), validate a subset with
92
- `matches_structure()` instead of exact equality.
88
+ [`matches_structure()`](https://solganis.github.io/assertpy2/matchers/#structural-matching) instead of exact equality.
93
89
 
94
- ## Type-aware autocomplete
90
+ ## [Type-aware autocomplete](https://solganis.github.io/assertpy2/type-safety/)
95
91
 
96
92
  `assert_that()` uses `@overload` to return type-specific Protocols.
97
93
  Your IDE shows only methods relevant to the value you're testing, not all 100+:
@@ -104,6 +100,8 @@ Your IDE shows only methods relevant to the value you're testing, not all 100+:
104
100
 
105
101
  9 type-specific Protocols instead of one `Any`. Works in PyCharm, VS Code, and any LSP-compatible editor.
106
102
 
103
+ See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) guide for the full walkthrough.
104
+
107
105
  ---
108
106
 
109
107
  ## Features
@@ -132,6 +130,7 @@ Your IDE shows only methods relevant to the value you're testing, not all 100+:
132
130
  - [**Structured errors**](https://solganis.github.io/assertpy2/errors/#structured-errors): `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes.
133
131
  - [**Rich pytest diffs**](https://solganis.github.io/assertpy2/errors/#rich-pytest-diffs): recursive structural diffs for lists, sets, strings, dicts, dataclasses, namedtuples, Pydantic models, and matcher-based assertions (`matches_structure()`, `satisfies()`, `each()`). Circular reference protection.
134
132
  - [**Snapshot testing**](https://solganis.github.io/assertpy2/testing/#snapshot-testing): store and compare data structures in JSON format.
133
+ - **Property-based tested**: comparison, selective-diff, matcher algebra, and collection logic are checked with [Hypothesis](https://hypothesis.readthedocs.io) against reference semantics, on top of 100% branch coverage.
135
134
 
136
135
  **Type safety**
137
136
 
@@ -153,7 +152,7 @@ See the [full documentation](https://solganis.github.io/assertpy2/) for all asse
153
152
 
154
153
  ---
155
154
 
156
- ## Integrations
155
+ ## [Integrations](https://solganis.github.io/assertpy2/integrations/)
157
156
 
158
157
  ### Allure
159
158
 
@@ -201,6 +200,8 @@ def step_impl(context, age):
201
200
 
202
201
  Available types: `PositiveInt`, `NonNegativeInt`, `PositiveFloat`, `NonEmptyString`, `BoolLike`.
203
202
 
203
+ See the [Integrations guide](https://solganis.github.io/assertpy2/integrations/) for attachment modes, configuration, and full examples.
204
+
204
205
  ---
205
206
 
206
207
  <p align="center">
@@ -0,0 +1,66 @@
1
+ """Runtime-checkable protocols for duck-typed object introspection.
2
+
3
+ The comparison and diff code accepts arbitrary user objects, so it inspects them structurally: pydantic
4
+ models (``model_dump``), ``attrs`` classes (``__attrs_attrs__``) and namedtuples (``_fields`` /
5
+ ``_asdict``). Expressing those shapes as ``@runtime_checkable`` protocols lets the diff code use
6
+ ``isinstance`` checks the type checker can follow - narrowing the value instead of probing it with
7
+ ``hasattr`` and then suppressing the attribute access.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Protocol, TypeGuard, runtime_checkable
13
+
14
+
15
+ @runtime_checkable
16
+ class SupportsModelDump(Protocol):
17
+ """A pydantic-style model exposing ``model_dump()``."""
18
+
19
+ def model_dump(self) -> dict[str, Any]: ...
20
+
21
+
22
+ @runtime_checkable
23
+ class NamedTupleLike(Protocol):
24
+ """A ``collections.namedtuple`` / ``typing.NamedTuple`` instance."""
25
+
26
+ _fields: tuple[str, ...]
27
+
28
+ def _asdict(self) -> dict[str, Any]: ...
29
+
30
+
31
+ @runtime_checkable
32
+ class AttrsInstance(Protocol):
33
+ """An ``attrs``-decorated instance exposing ``__attrs_attrs__``."""
34
+
35
+ __attrs_attrs__: tuple[Any, ...]
36
+
37
+
38
+ @runtime_checkable
39
+ class MappingLike(Protocol):
40
+ """A dict-like object that can be iterated over and subscripted by key."""
41
+
42
+ def keys(self) -> Any: ...
43
+
44
+ def __iter__(self) -> Any: ...
45
+
46
+ def __getitem__(self, key: Any) -> Any: ...
47
+
48
+
49
+ def is_model_dump_object(obj: object) -> TypeGuard[SupportsModelDump]:
50
+ """Return whether ``obj`` exposes a callable ``model_dump()`` (e.g. a pydantic model)."""
51
+ return isinstance(obj, SupportsModelDump) and callable(obj.model_dump)
52
+
53
+
54
+ def is_namedtuple(obj: object) -> TypeGuard[NamedTupleLike]:
55
+ """Return whether ``obj`` is a namedtuple instance (a ``tuple`` carrying ``_fields``/``_asdict``)."""
56
+ return isinstance(obj, tuple) and isinstance(obj, NamedTupleLike)
57
+
58
+
59
+ def is_attrs_instance(obj: object) -> TypeGuard[AttrsInstance]:
60
+ """Return whether ``obj`` is an ``attrs``-decorated instance."""
61
+ return isinstance(obj, AttrsInstance)
62
+
63
+
64
+ def is_mapping_like(obj: object) -> TypeGuard[MappingLike]:
65
+ """Return whether ``obj`` is dict-like: iterable with ``keys()`` and ``[]`` access."""
66
+ return isinstance(obj, MappingLike) and callable(obj.keys)
@@ -1,9 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
- import logging
4
3
  from typing import TYPE_CHECKING, Any
5
4
 
6
5
  if TYPE_CHECKING:
6
+ import logging
7
+
7
8
  from ._compat import Self
8
9
  from .errors import DiffResult
9
10
 
@@ -9,17 +9,18 @@ import logging
9
9
  import os
10
10
  import sys
11
11
  import types
12
- from collections.abc import Callable, Iterator
13
- from typing import TYPE_CHECKING, overload
12
+ from typing import TYPE_CHECKING, Final, overload
14
13
 
15
14
  if TYPE_CHECKING:
16
15
  import datetime
17
16
  import pathlib
17
+ from collections.abc import Callable, Iterator
18
18
 
19
19
  from ._compat import Self
20
20
  from ._typing import (
21
21
  _BytesAssertion,
22
22
  _CallableAssertion,
23
+ _CoreAssertion,
23
24
  _DateAssertion,
24
25
  _DictAssertion,
25
26
  _IterableAssertion,
@@ -47,13 +48,13 @@ from .snapshot import SnapshotMixin
47
48
  from .string import StringMixin
48
49
  from .warning import WarningMixin
49
50
 
50
- __version__ = "2.8.0"
51
+ __version__ = "2.8.1"
51
52
 
52
53
  __tracebackhide__ = True # clean tracebacks via py.test integration
53
54
  contextlib.__tracebackhide__ = True # ty: ignore[unresolved-attribute] # pytest monkey-patch
54
55
 
55
56
  # assertpy files
56
- ASSERTPY_FILES = [
57
+ ASSERTPY_FILES: Final = [
57
58
  os.path.join("assertpy2", file)
58
59
  for file in [
59
60
  "assertpy.py",
@@ -253,11 +254,16 @@ def assert_that(val: bytes | bytearray, description: str = "") -> _BytesAssertio
253
254
  def assert_that(val: Callable[..., object], description: str = "") -> _CallableAssertion: ...
254
255
 
255
256
 
257
+ # Fallback returns the concrete AssertionBuilder so object- and union-typed values keep the full API.
258
+ # The specific protocols are not assignable to AssertionBuilder, so mypy --strict reports overload-overlap
259
+ # for each specific overload and pyright one reportOverlappingOverload; ty (the gate) does not flag it. Kept
260
+ # intentionally - returning _CoreAssertion here would strip type-specific assertions from object/union values.
256
261
  @overload
257
262
  def assert_that(val: object, description: str = "") -> AssertionBuilder: ...
258
263
 
259
264
 
260
- def assert_that(val, description=""):
265
+ # Return the common base protocol so each overload stays consistent with the impl (no reportInconsistentOverload).
266
+ def assert_that(val, description="") -> _CoreAssertion:
261
267
  """Set the value to be tested, plus an optional description, and allow assertions to be called.
262
268
 
263
269
  This is a factory method for the :class:`AssertionBuilder`, and the single most important
@@ -69,7 +69,7 @@ class AsyncAssertionBuilder:
69
69
  method = getattr(builder, name)
70
70
  method(*args, **kwargs)
71
71
  return builder
72
- except AssertionError as exc:
72
+ except AssertionError as exc: # noqa: PERF203 # retry-on-failure needs the try/except per poll iteration
73
73
  last_error = exc
74
74
  if loop.time() >= deadline:
75
75
  raise AssertionError(
@@ -2,8 +2,9 @@ from __future__ import annotations
2
2
 
3
3
  import collections.abc
4
4
  import dataclasses
5
- from typing import TYPE_CHECKING
5
+ from typing import TYPE_CHECKING, Any, Final
6
6
 
7
+ from ._introspection import is_model_dump_object, is_namedtuple
7
8
  from ._mixin_base import _MixinBase
8
9
  from .errors import DiffEntry, DiffResult
9
10
  from .matchers import Matcher, StructureMatcher
@@ -13,11 +14,7 @@ if TYPE_CHECKING:
13
14
 
14
15
  __tracebackhide__ = True
15
16
 
16
- _SENTINEL = object()
17
-
18
-
19
- def _is_model_dump_object(obj: object) -> bool:
20
- return hasattr(obj, "model_dump") and callable(obj.model_dump)
17
+ _SENTINEL: Final = object()
21
18
 
22
19
 
23
20
  class BaseMixin(_MixinBase):
@@ -80,7 +77,9 @@ class BaseMixin(_MixinBase):
80
77
  assert_that({'a': 1, 'b': 2, 'c': 3}).is_equal_to({'a': 1}, ignore=['b', 'c'])
81
78
 
82
79
  # ignore nested keys
83
- assert_that({'a': {'b': 2, 'c': 3, 'd': 4}}).is_equal_to({'a': {'d': 4}}, ignore=[('a', 'b'), ('a', 'c')])
80
+ assert_that({'a': {'b': 2, 'c': 3, 'd': 4}}).is_equal_to(
81
+ {'a': {'d': 4}}, ignore=[('a', 'b'), ('a', 'c')]
82
+ )
84
83
 
85
84
  When the val is *dict-like*, only certain keys can be *included* when checking equality::
86
85
 
@@ -133,8 +132,8 @@ class BaseMixin(_MixinBase):
133
132
  if self._dict_not_equal(self.val, other, ignore=ignore, include=include):
134
133
  self._dict_err(self.val, other, ignore=ignore, include=include)
135
134
  elif ignore or include:
136
- val_is_namedtuple = isinstance(self.val, tuple) and hasattr(self.val, "_fields")
137
- other_is_namedtuple = isinstance(other, tuple) and hasattr(other, "_fields")
135
+ val_is_namedtuple = is_namedtuple(self.val)
136
+ other_is_namedtuple = is_namedtuple(other)
138
137
  if (
139
138
  isinstance(self.val, (list, tuple))
140
139
  and isinstance(other, (list, tuple))
@@ -208,14 +207,9 @@ class BaseMixin(_MixinBase):
208
207
  return sub_entries
209
208
  return [DiffEntry(path=path, actual=actual_value, expected=expected_value)]
210
209
 
211
- if (
212
- hasattr(actual, "_fields")
213
- and isinstance(actual, tuple)
214
- and hasattr(expected, "_fields")
215
- and isinstance(expected, tuple)
216
- ):
210
+ if is_namedtuple(actual) and is_namedtuple(expected):
217
211
  entries: list[DiffEntry] = []
218
- for field in actual._fields: # ty: ignore[not-iterable] # guarded by hasattr check above
212
+ for field in actual._fields:
219
213
  actual_value = getattr(actual, field)
220
214
  expected_value = getattr(expected, field, _SENTINEL)
221
215
  path = f"{_prefix}.{field}"
@@ -223,9 +217,11 @@ class BaseMixin(_MixinBase):
223
217
  entries.append(DiffEntry(path=path, actual=actual_value, expected=None))
224
218
  elif actual_value != expected_value:
225
219
  entries.extend(_field_entries(actual_value, expected_value, path))
226
- for field in expected._fields: # ty: ignore[not-iterable] # guarded by hasattr check above
227
- if not hasattr(actual, field):
228
- entries.append(DiffEntry(path=f"{_prefix}.{field}", actual=None, expected=getattr(expected, field)))
220
+ entries.extend(
221
+ DiffEntry(path=f"{_prefix}.{field}", actual=None, expected=getattr(expected, field))
222
+ for field in expected._fields
223
+ if not hasattr(actual, field)
224
+ )
229
225
  return DiffResult(kind="namedtuple", entries=entries)
230
226
  if (
231
227
  dataclasses.is_dataclass(actual)
@@ -248,9 +244,9 @@ class BaseMixin(_MixinBase):
248
244
  if actual_value != expected_value:
249
245
  entries.extend(_field_entries(actual_value, expected_value, path))
250
246
  return DiffResult(kind="dataclass", entries=entries)
251
- if _is_model_dump_object(actual) and _is_model_dump_object(expected):
252
- actual_dict = actual.model_dump() # ty: ignore[unresolved-attribute] # guarded by _is_model_dump_object check above
253
- expected_dict = expected.model_dump() # ty: ignore[unresolved-attribute] # guarded by _is_model_dump_object check above
247
+ if is_model_dump_object(actual) and is_model_dump_object(expected):
248
+ actual_dict = actual.model_dump()
249
+ expected_dict = expected.model_dump()
254
250
  entries = []
255
251
  for key in sorted(set(actual_dict) | set(expected_dict)):
256
252
  path = f"{_prefix}.{key}" if _prefix else f".{key}"
@@ -354,37 +350,34 @@ class BaseMixin(_MixinBase):
354
350
  DiffEntry(path=f"{prefix}.{field.name}", actual=actual_value, expected=expected_value)
355
351
  )
356
352
  return entries or None
357
- if (
358
- hasattr(actual, "_fields")
359
- and isinstance(actual, tuple)
360
- and hasattr(expected, "_fields")
361
- and isinstance(expected, tuple)
362
- ):
353
+ if is_namedtuple(actual) and is_namedtuple(expected):
363
354
  child_seen = _seen | {id(actual), id(expected)}
364
355
  entries = []
365
- for field in actual._fields: # ty: ignore[not-iterable] # guarded by hasattr check above
366
- actual_value = getattr(actual, field)
367
- expected_value = getattr(expected, field, _SENTINEL)
356
+ for field_name in actual._fields:
357
+ actual_value = getattr(actual, field_name)
358
+ expected_value = getattr(expected, field_name, _SENTINEL)
368
359
  if expected_value is _SENTINEL:
369
- entries.append(DiffEntry(path=f"{prefix}.{field}", actual=actual_value, expected=None))
360
+ entries.append(DiffEntry(path=f"{prefix}.{field_name}", actual=actual_value, expected=None))
370
361
  elif actual_value != expected_value:
371
362
  sub_entries = BaseMixin._sub_diff_entries(
372
- actual_value, expected_value, f"{prefix}.{field}", _seen=child_seen
363
+ actual_value, expected_value, f"{prefix}.{field_name}", _seen=child_seen
373
364
  )
374
365
  if sub_entries is not None:
375
366
  entries.extend(sub_entries)
376
367
  else:
377
368
  entries.append(
378
- DiffEntry(path=f"{prefix}.{field}", actual=actual_value, expected=expected_value)
369
+ DiffEntry(path=f"{prefix}.{field_name}", actual=actual_value, expected=expected_value)
379
370
  )
380
- for field in expected._fields: # ty: ignore[not-iterable] # guarded by hasattr check above
381
- if not hasattr(actual, field):
382
- entries.append(DiffEntry(path=f"{prefix}.{field}", actual=None, expected=getattr(expected, field)))
371
+ for field_name in expected._fields:
372
+ if not hasattr(actual, field_name):
373
+ entries.append(
374
+ DiffEntry(path=f"{prefix}.{field_name}", actual=None, expected=getattr(expected, field_name))
375
+ )
383
376
  return entries or None
384
- if _is_model_dump_object(actual) and _is_model_dump_object(expected):
377
+ if is_model_dump_object(actual) and is_model_dump_object(expected):
385
378
  child_seen = _seen | {id(actual), id(expected)}
386
- actual_dict = actual.model_dump() # ty: ignore[unresolved-attribute] # guarded by _is_model_dump_object check above
387
- expected_dict = expected.model_dump() # ty: ignore[unresolved-attribute] # guarded by _is_model_dump_object check above
379
+ actual_dict = actual.model_dump()
380
+ expected_dict = expected.model_dump()
388
381
  entries = []
389
382
  for key in sorted(set(actual_dict) | set(expected_dict)):
390
383
  path = f"{prefix}.{key}"
@@ -494,7 +487,7 @@ class BaseMixin(_MixinBase):
494
487
  raise TypeError("given arg must be a Matcher or callable")
495
488
  return self
496
489
 
497
- def matches_structure(self, spec: dict) -> Self:
490
+ def matches_structure(self, spec: dict[Any, Any]) -> Self:
498
491
  """Asserts that val is a dict matching the given structure specification.
499
492
 
500
493
  Each key in ``spec`` maps to either a :class:`~assertpy2.matchers.Matcher`, a raw value
@@ -1,7 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
- _BOOL_TRUE = frozenset({"true", "yes", "1", "on"})
4
- _BOOL_FALSE = frozenset({"false", "no", "0", "off"})
3
+ from types import MappingProxyType
4
+ from typing import Final
5
+
6
+ _BOOL_TRUE: Final = frozenset({"true", "yes", "1", "on"})
7
+ _BOOL_FALSE: Final = frozenset({"false", "no", "0", "off"})
5
8
 
6
9
 
7
10
  def _with_pattern(pattern):
@@ -51,13 +54,15 @@ def _bool_like(text):
51
54
  raise ValueError(f"expected boolean-like value, got {text!r}")
52
55
 
53
56
 
54
- ASSERTPY_TYPES = {
55
- "PositiveInt": _positive_int,
56
- "NonNegativeInt": _non_negative_int,
57
- "PositiveFloat": _positive_float,
58
- "NonEmptyString": _non_empty_string,
59
- "BoolLike": _bool_like,
60
- }
57
+ ASSERTPY_TYPES: Final = MappingProxyType(
58
+ {
59
+ "PositiveInt": _positive_int,
60
+ "NonNegativeInt": _non_negative_int,
61
+ "PositiveFloat": _positive_float,
62
+ "NonEmptyString": _non_empty_string,
63
+ "BoolLike": _bool_like,
64
+ }
65
+ )
61
66
 
62
67
 
63
68
  def register_assertpy_types() -> None:
@@ -1,13 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
- import collections
4
- from collections.abc import Callable, Iterable
3
+ import collections.abc
5
4
  from typing import TYPE_CHECKING, Any
6
5
 
6
+ from ._introspection import is_mapping_like
7
7
  from ._mixin_base import _MixinBase
8
8
  from .matchers import BaseMatcher
9
9
 
10
10
  if TYPE_CHECKING:
11
+ from collections.abc import Callable, Iterable
12
+
11
13
  from ._compat import Self
12
14
 
13
15
  __tracebackhide__ = True
@@ -91,7 +93,7 @@ class CollectionMixin(_MixinBase):
91
93
  raise ValueError("one or more superset args must be given")
92
94
 
93
95
  missing = []
94
- if hasattr(self.val, "keys") and callable(self.val.keys) and hasattr(self.val, "__getitem__"):
96
+ if is_mapping_like(self.val):
95
97
  superdict = {}
96
98
  for superset_index, superset in enumerate(supersets):
97
99
  self._check_dict_like(superset, check_values=False, name=f"arg #{superset_index + 1}")
@@ -114,12 +116,10 @@ class CollectionMixin(_MixinBase):
114
116
  try:
115
117
  for key in superset:
116
118
  superset_values.add(key)
117
- except TypeError:
119
+ except TypeError: # noqa: PERF203 # per-item fallback: a non-iterable superset is treated as a single value
118
120
  superset_values.add(superset)
119
121
 
120
- for item in self.val:
121
- if item not in superset_values:
122
- missing.append(item)
122
+ missing.extend(item for item in self.val if item not in superset_values)
123
123
  if missing:
124
124
  return self.error(
125
125
  f"Expected <{self.val}> to be subset of {self._fmt_items(superset_values)}, "
@@ -128,10 +128,7 @@ class ContainsMixin(_MixinBase):
128
128
  if items[0] in self.val:
129
129
  return self.error(f"Expected <{self.val}> to not contain item <{items[0]}>, but did.")
130
130
  else:
131
- found = []
132
- for item in items:
133
- if item in self.val:
134
- found.append(item)
131
+ found = [item for item in items if item in self.val]
135
132
  if found:
136
133
  return self.error(
137
134
  f"Expected <{self.val}> to not contain items {self._fmt_items(items)},"
@@ -165,20 +162,14 @@ class ContainsMixin(_MixinBase):
165
162
  if len(items) == 0:
166
163
  raise ValueError("one or more args must be given")
167
164
  else:
168
- extra = []
169
- for item in self.val:
170
- if item not in items:
171
- extra.append(item)
165
+ extra = [item for item in self.val if item not in items]
172
166
  if extra:
173
167
  return self.error(
174
168
  f"Expected <{self.val}> to contain only {self._fmt_items(items)},"
175
169
  f" but did contain {self._fmt_items(extra)}."
176
170
  )
177
171
 
178
- missing = []
179
- for item in items:
180
- if item not in self.val:
181
- missing.append(item)
172
+ missing = [item for item in items if item not in self.val]
182
173
  if missing:
183
174
  return self.error(
184
175
  f"Expected <{self.val}> to contain only {self._fmt_items(items)},"
@@ -359,11 +350,14 @@ class ContainsMixin(_MixinBase):
359
350
  if val_list != list(items):
360
351
  val_counts = Counter(val_list)
361
352
  item_counts = Counter(items)
362
- entries = []
363
- for item in sorted((val_counts - item_counts).elements(), key=repr):
364
- entries.append(DiffEntry(path="extra", actual=item, expected=None))
365
- for item in sorted((item_counts - val_counts).elements(), key=repr):
366
- entries.append(DiffEntry(path="missing", actual=None, expected=item))
353
+ entries = [
354
+ DiffEntry(path="extra", actual=item, expected=None)
355
+ for item in sorted((val_counts - item_counts).elements(), key=repr)
356
+ ]
357
+ entries.extend(
358
+ DiffEntry(path="missing", actual=None, expected=item)
359
+ for item in sorted((item_counts - val_counts).elements(), key=repr)
360
+ )
367
361
  diff = DiffResult(kind="contains", entries=entries) if entries else None
368
362
  return self.error(
369
363
  f"Expected <{self.val}> to contain exactly {self._fmt_items(items)}, but did not.",
@@ -38,7 +38,7 @@ class DateMixin(_MixinBase):
38
38
 
39
39
  See Also:
40
40
  :meth:`~assertpy.string.NumericMixin.is_less_than` - numeric assertion, but also works with datetime BR
41
- :meth:`~assertpy.string.NumericMixin.is_less_than_or_equal_to` - numeric assertion, but also works with datetime
41
+ :meth:`~assertpy.string.NumericMixin.is_less_than_or_equal_to` - also works with datetime
42
42
  """
43
43
  if type(self.val) is not datetime.datetime:
44
44
  raise TypeError(f"val must be datetime, but was type <{type(self.val).__name__}>")
@@ -75,7 +75,7 @@ class DateMixin(_MixinBase):
75
75
 
76
76
  See Also:
77
77
  :meth:`~assertpy.string.NumericMixin.is_greater_than` - numeric assertion, but also works with datetime BR
78
- :meth:`~assertpy.string.NumericMixin.is_greater_than_or_equal_to` - numeric assertion, but also works with datetime
78
+ :meth:`~assertpy.string.NumericMixin.is_greater_than_or_equal_to` - also works with datetime
79
79
  """
80
80
  if type(self.val) is not datetime.datetime:
81
81
  raise TypeError(f"val must be datetime, but was type <{type(self.val).__name__}>")