assertpy2 2.8.1__tar.gz → 2.9.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 (124) hide show
  1. {assertpy2-2.8.1 → assertpy2-2.9.0}/.github/workflows/ci.yml +9 -3
  2. assertpy2-2.9.0/.github/workflows/mutation.yml +63 -0
  3. {assertpy2-2.8.1 → assertpy2-2.9.0}/PKG-INFO +2 -2
  4. {assertpy2-2.8.1 → assertpy2-2.9.0}/README.md +1 -1
  5. {assertpy2-2.8.1 → assertpy2-2.9.0}/assertpy2/_mixin_base.py +10 -3
  6. {assertpy2-2.8.1 → assertpy2-2.9.0}/assertpy2/_typing.py +7 -2
  7. {assertpy2-2.8.1 → assertpy2-2.9.0}/assertpy2/assertpy.py +5 -27
  8. {assertpy2-2.8.1 → assertpy2-2.9.0}/assertpy2/async_assertions.py +3 -4
  9. {assertpy2-2.8.1 → assertpy2-2.9.0}/assertpy2/base.py +8 -8
  10. {assertpy2-2.8.1 → assertpy2-2.9.0}/assertpy2/collection.py +12 -14
  11. {assertpy2-2.8.1 → assertpy2-2.9.0}/assertpy2/contains.py +18 -12
  12. {assertpy2-2.8.1 → assertpy2-2.9.0}/assertpy2/date.py +24 -32
  13. {assertpy2-2.8.1 → assertpy2-2.9.0}/assertpy2/dict.py +8 -8
  14. {assertpy2-2.8.1 → assertpy2-2.9.0}/assertpy2/dynamic.py +1 -1
  15. {assertpy2-2.8.1 → assertpy2-2.9.0}/assertpy2/exception.py +1 -1
  16. {assertpy2-2.8.1 → assertpy2-2.9.0}/assertpy2/extracting.py +8 -4
  17. {assertpy2-2.8.1 → assertpy2-2.9.0}/assertpy2/file.py +8 -4
  18. {assertpy2-2.8.1 → assertpy2-2.9.0}/assertpy2/helpers.py +54 -40
  19. {assertpy2-2.8.1 → assertpy2-2.9.0}/assertpy2/matchers.py +25 -5
  20. {assertpy2-2.8.1 → assertpy2-2.9.0}/assertpy2/numeric.py +49 -61
  21. {assertpy2-2.8.1 → assertpy2-2.9.0}/assertpy2/pytest_plugin.py +12 -6
  22. assertpy2-2.9.0/assertpy2/snapshot.py +232 -0
  23. {assertpy2-2.8.1 → assertpy2-2.9.0}/assertpy2/string.py +28 -46
  24. {assertpy2-2.8.1 → assertpy2-2.9.0}/docs/assertions.md +4 -2
  25. {assertpy2-2.8.1 → assertpy2-2.9.0}/docs/comparison.md +1 -1
  26. {assertpy2-2.8.1 → assertpy2-2.9.0}/docs/type-safety.md +3 -2
  27. {assertpy2-2.8.1 → assertpy2-2.9.0}/pyproject.toml +11 -4
  28. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_async.py +7 -0
  29. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_collection.py +7 -0
  30. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_core.py +6 -6
  31. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_custom_dict.py +25 -25
  32. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_datetime.py +62 -2
  33. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_dict.py +1 -1
  34. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_dict_compare.py +29 -0
  35. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_extracting.py +2 -1
  36. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_file.py +9 -0
  37. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_in.py +11 -0
  38. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_list.py +7 -0
  39. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_matchers.py +10 -0
  40. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_matchers_phase3.py +3 -0
  41. assertpy2-2.9.0/tests/test_mutation_hardening.py +106 -0
  42. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_namedtuple.py +1 -1
  43. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_numbers.py +30 -0
  44. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_pytest_plugin.py +9 -0
  45. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_recursive_compare.py +5 -0
  46. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_rich_diff.py +7 -2
  47. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_snapshots.py +34 -0
  48. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_structural.py +14 -0
  49. assertpy2-2.9.0/tests/test_typing.py +50 -0
  50. assertpy2-2.9.0/uv.lock +2523 -0
  51. assertpy2-2.8.1/assertpy2/snapshot.py +0 -197
  52. assertpy2-2.8.1/uv.lock +0 -1359
  53. {assertpy2-2.8.1 → assertpy2-2.9.0}/.codecov.yml +0 -0
  54. {assertpy2-2.8.1 → assertpy2-2.9.0}/.gitattributes +0 -0
  55. {assertpy2-2.8.1 → assertpy2-2.9.0}/.github/dependabot.yml +0 -0
  56. {assertpy2-2.8.1 → assertpy2-2.9.0}/.github/workflows/codeql.yml +0 -0
  57. {assertpy2-2.8.1 → assertpy2-2.9.0}/.github/workflows/docs.yml +0 -0
  58. {assertpy2-2.8.1 → assertpy2-2.9.0}/.github/workflows/publish.yml +0 -0
  59. {assertpy2-2.8.1 → assertpy2-2.9.0}/.github/workflows/scorecard.yml +0 -0
  60. {assertpy2-2.8.1 → assertpy2-2.9.0}/.github/workflows/zizmor.yml +0 -0
  61. {assertpy2-2.8.1 → assertpy2-2.9.0}/.gitignore +0 -0
  62. {assertpy2-2.8.1 → assertpy2-2.9.0}/CONTRIBUTING.md +0 -0
  63. {assertpy2-2.8.1 → assertpy2-2.9.0}/LICENSE +0 -0
  64. {assertpy2-2.8.1 → assertpy2-2.9.0}/SECURITY.md +0 -0
  65. {assertpy2-2.8.1 → assertpy2-2.9.0}/assertpy2/__init__.py +0 -0
  66. {assertpy2-2.8.1 → assertpy2-2.9.0}/assertpy2/_compat.py +0 -0
  67. {assertpy2-2.8.1 → assertpy2-2.9.0}/assertpy2/_introspection.py +0 -0
  68. {assertpy2-2.8.1 → assertpy2-2.9.0}/assertpy2/behave_matchers.py +0 -0
  69. {assertpy2-2.8.1 → assertpy2-2.9.0}/assertpy2/bytes_mixin.py +0 -0
  70. {assertpy2-2.8.1 → assertpy2-2.9.0}/assertpy2/errors.py +0 -0
  71. {assertpy2-2.8.1 → assertpy2-2.9.0}/assertpy2/json_mixin.py +0 -0
  72. {assertpy2-2.8.1 → assertpy2-2.9.0}/assertpy2/py.typed +0 -0
  73. {assertpy2-2.8.1 → assertpy2-2.9.0}/assertpy2/warning.py +0 -0
  74. {assertpy2-2.8.1 → assertpy2-2.9.0}/docs/assets/diff-equal.png +0 -0
  75. {assertpy2-2.8.1 → assertpy2-2.9.0}/docs/assets/diff-equal.svg +0 -0
  76. {assertpy2-2.8.1 → assertpy2-2.9.0}/docs/assets/diff-match.svg +0 -0
  77. {assertpy2-2.8.1 → assertpy2-2.9.0}/docs/assets/diff-sequence.svg +0 -0
  78. {assertpy2-2.8.1 → assertpy2-2.9.0}/docs/assets/diff-set.svg +0 -0
  79. {assertpy2-2.8.1 → assertpy2-2.9.0}/docs/data.md +0 -0
  80. {assertpy2-2.8.1 → assertpy2-2.9.0}/docs/errors.md +0 -0
  81. {assertpy2-2.8.1 → assertpy2-2.9.0}/docs/extending.md +0 -0
  82. {assertpy2-2.8.1 → assertpy2-2.9.0}/docs/fluent.md +0 -0
  83. {assertpy2-2.8.1 → assertpy2-2.9.0}/docs/getting-started.md +0 -0
  84. {assertpy2-2.8.1 → assertpy2-2.9.0}/docs/index.md +0 -0
  85. {assertpy2-2.8.1 → assertpy2-2.9.0}/docs/integrations.md +0 -0
  86. {assertpy2-2.8.1 → assertpy2-2.9.0}/docs/logo-dark.svg +0 -0
  87. {assertpy2-2.8.1 → assertpy2-2.9.0}/docs/logo.svg +0 -0
  88. {assertpy2-2.8.1 → assertpy2-2.9.0}/docs/matchers.md +0 -0
  89. {assertpy2-2.8.1 → assertpy2-2.9.0}/docs/migration.md +0 -0
  90. {assertpy2-2.8.1 → assertpy2-2.9.0}/docs/testing.md +0 -0
  91. {assertpy2-2.8.1 → assertpy2-2.9.0}/mkdocs.yml +0 -0
  92. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_behave_matchers.py +0 -0
  93. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_bool.py +0 -0
  94. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_bytes.py +0 -0
  95. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_class.py +0 -0
  96. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_custom_list.py +0 -0
  97. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_description.py +0 -0
  98. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_dyn.py +0 -0
  99. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_equals.py +0 -0
  100. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_errors.py +0 -0
  101. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_expected_exception.py +0 -0
  102. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_expected_warning.py +0 -0
  103. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_extensions.py +0 -0
  104. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_fail.py +0 -0
  105. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_grouped_soft.py +0 -0
  106. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_json.py +0 -0
  107. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_matcher_registry.py +0 -0
  108. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_none.py +0 -0
  109. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_not.py +0 -0
  110. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_overloads.py +0 -0
  111. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_phase2.py +0 -0
  112. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_pipeline.py +0 -0
  113. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_property_based.py +0 -0
  114. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_readme.py +0 -0
  115. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_recursive_compare_attrs.py +0 -0
  116. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_recursive_compare_pydantic.py +0 -0
  117. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_regex_groups.py +0 -0
  118. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_same_as.py +0 -0
  119. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_soft.py +0 -0
  120. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_soft_fail.py +0 -0
  121. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_string.py +0 -0
  122. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_traceback.py +0 -0
  123. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_type.py +0 -0
  124. {assertpy2-2.8.1 → assertpy2-2.9.0}/tests/test_warn.py +0 -0
@@ -68,7 +68,7 @@ jobs:
68
68
  python-version: "3.14"
69
69
 
70
70
  - name: Install dependencies
71
- run: uv sync --extra json
71
+ run: uv sync --extra json --group typecheck
72
72
 
73
73
  - name: Ruff check
74
74
  run: uv run ruff check .
@@ -76,5 +76,11 @@ jobs:
76
76
  - name: Ruff format check
77
77
  run: uv run ruff format --check .
78
78
 
79
- - name: Type check
80
- run: uv run ty check assertpy2/
79
+ - name: Type check (ty)
80
+ run: uv run ty check assertpy2/ tests/test_typing.py
81
+
82
+ - name: Type check (mypy --strict, public typing surface)
83
+ run: uv run mypy --strict --follow-imports=silent tests/test_typing.py
84
+
85
+ - name: Type check (pyright, public typing surface)
86
+ run: uv run pyright tests/test_typing.py
@@ -0,0 +1,63 @@
1
+ name: Mutation
2
+
3
+ on:
4
+ schedule:
5
+ - cron: "0 4 * * 1" # weekly, Monday 04:00 UTC
6
+ workflow_dispatch: {}
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ concurrency:
12
+ group: ${{ github.workflow }}
13
+ cancel-in-progress: true
14
+
15
+ jobs:
16
+ mutation:
17
+ runs-on: ubuntu-latest
18
+ timeout-minutes: 120
19
+ strategy:
20
+ fail-fast: false
21
+ matrix:
22
+ module: [base, numeric, string, contains, collection, dict, date, matchers, helpers]
23
+ steps:
24
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
25
+ with:
26
+ persist-credentials: false
27
+
28
+ - name: Install uv
29
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
30
+
31
+ - name: Set up Python
32
+ uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
33
+ with:
34
+ python-version: "3.14"
35
+
36
+ - name: Install dependencies
37
+ run: uv sync --extra json --group mutation
38
+
39
+ - name: Run mutation testing
40
+ env:
41
+ MODULE: ${{ matrix.module }}
42
+ run: |
43
+ cat > cosmic-ray.toml <<EOF
44
+ [cosmic-ray]
45
+ module-path = "assertpy2/${MODULE}.py"
46
+ timeout = 30.0
47
+ excluded-modules = []
48
+ test-command = ".venv/bin/python -m pytest tests/ -x -q -p no:cacheprovider"
49
+
50
+ [cosmic-ray.distributor]
51
+ name = "local"
52
+ EOF
53
+ uv run cosmic-ray baseline cosmic-ray.toml
54
+ uv run cosmic-ray init cosmic-ray.toml session.sqlite
55
+ uv run cosmic-ray exec cosmic-ray.toml session.sqlite
56
+ {
57
+ echo "### Mutation: ${MODULE}.py"
58
+ echo ""
59
+ echo "Surviving mutants: $(uv run cr-rate session.sqlite)%"
60
+ } >> "$GITHUB_STEP_SUMMARY"
61
+ echo "::group::Surviving mutants (${MODULE}.py)"
62
+ uv run cr-report session.sqlite
63
+ echo "::endgroup::"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertpy2
3
- Version: 2.8.1
3
+ Version: 2.9.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
@@ -119,7 +119,7 @@ assertpy2 reports the [exact path to every difference](https://solganis.github.i
119
119
  assert_that(response).is_equal_to(expected)
120
120
  ```
121
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">
122
+ <img src="https://raw.githubusercontent.com/Solganis/assertpy2/main/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
123
 
124
124
  Recursive diffs work for dicts, dataclasses, namedtuples, attrs, and Pydantic models.
125
125
  For responses with dynamic fields (IDs, timestamps), validate a subset with
@@ -81,7 +81,7 @@ assertpy2 reports the [exact path to every difference](https://solganis.github.i
81
81
  assert_that(response).is_equal_to(expected)
82
82
  ```
83
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">
84
+ <img src="https://raw.githubusercontent.com/Solganis/assertpy2/main/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
85
 
86
86
  Recursive diffs work for dicts, dataclasses, namedtuples, attrs, and Pydantic models.
87
87
  For responses with dynamic fields (IDs, timestamps), validate a subset with
@@ -58,15 +58,22 @@ class _MixinBase:
58
58
 
59
59
  def _validate_close_to_args(self, val: object, other: object, tolerance: object) -> None: ...
60
60
 
61
- def _check_dict_like(
61
+ def _is_dict_like(
62
+ self,
63
+ d: object,
64
+ check_keys: bool = ...,
65
+ check_values: bool = ...,
66
+ check_getitem: bool = ...,
67
+ ) -> bool: ...
68
+
69
+ def _require_dict_like(
62
70
  self,
63
71
  d: object,
64
72
  check_keys: bool = ...,
65
73
  check_values: bool = ...,
66
74
  check_getitem: bool = ...,
67
75
  name: str = ...,
68
- return_as_bool: bool = ...,
69
- ) -> bool | None: ...
76
+ ) -> None: ...
70
77
 
71
78
  def _check_iterable(self, val: object, check_getitem: bool = ..., name: str = ...) -> None: ...
72
79
 
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from typing import TYPE_CHECKING
4
4
 
5
5
  if TYPE_CHECKING:
6
- from collections.abc import Callable
6
+ from collections.abc import Callable, Hashable
7
7
  from pathlib import Path
8
8
  from typing import Any, Protocol
9
9
 
@@ -12,12 +12,17 @@ if TYPE_CHECKING:
12
12
  from .async_assertions import AsyncAssertionBuilder
13
13
  from .matchers import Matcher
14
14
 
15
+ # ``ignore``/``include`` accept a single key, a nested-path tuple, or a list/set/frozenset of them.
16
+ _KeySpecs = Hashable | list[Hashable] | set[Hashable] | frozenset[Hashable]
17
+
15
18
  class _CoreAssertion(Protocol):
16
19
  """Base protocol with assertions available for all types."""
17
20
 
18
21
  # BaseMixin
19
22
  def described_as(self, description: str) -> Self: ...
20
- def is_equal_to(self, other: object, **kwargs: object) -> Self: ...
23
+ def is_equal_to(
24
+ self, other: object, *, ignore: _KeySpecs | None = ..., include: _KeySpecs | None = ...
25
+ ) -> Self: ...
21
26
  def is_not_equal_to(self, other: object) -> Self: ...
22
27
  def is_same_as(self, other: object) -> Self: ...
23
28
  def is_not_same_as(self, other: object) -> Self: ...
@@ -48,37 +48,15 @@ from .snapshot import SnapshotMixin
48
48
  from .string import StringMixin
49
49
  from .warning import WarningMixin
50
50
 
51
- __version__ = "2.8.1"
51
+ __version__ = "2.9.0"
52
52
 
53
53
  __tracebackhide__ = True # clean tracebacks via py.test integration
54
54
  contextlib.__tracebackhide__ = True # ty: ignore[unresolved-attribute] # pytest monkey-patch
55
55
 
56
- # assertpy files
56
+ # assertpy2 source files, used to strip internal frames when locating the caller for warn-mode messages.
57
+ # Derived from the package directory so new modules are covered automatically (no hand-maintained list).
57
58
  ASSERTPY_FILES: Final = [
58
- os.path.join("assertpy2", file)
59
- for file in [
60
- "assertpy.py",
61
- "async_assertions.py",
62
- "base.py",
63
- "bytes_mixin.py",
64
- "collection.py",
65
- "contains.py",
66
- "date.py",
67
- "dict.py",
68
- "dynamic.py",
69
- "errors.py",
70
- "exception.py",
71
- "extracting.py",
72
- "file.py",
73
- "helpers.py",
74
- "json_mixin.py",
75
- "matchers.py",
76
- "numeric.py",
77
- "pytest_plugin.py",
78
- "snapshot.py",
79
- "string.py",
80
- "warning.py",
81
- ]
59
+ os.path.join("assertpy2", name) for name in os.listdir(os.path.dirname(__file__)) if name.endswith(".py")
82
60
  ]
83
61
 
84
62
  # soft assertions (contextvars for thread/async safety)
@@ -619,7 +597,7 @@ class AssertionBuilder(
619
597
  def error(self, msg, *, actual=None, expected=None, diff=None) -> Self:
620
598
  """Helper to raise an ``AssertionError`` with the given message.
621
599
 
622
- If an error description is set by :meth:`~assertpy.base.BaseMixin.described_as`, then that
600
+ If an error description is set by :meth:`~assertpy2.base.BaseMixin.described_as`, then that
623
601
  description is prepended to the error message.
624
602
 
625
603
  When structured data (``actual``, ``expected``, or ``diff``) is provided, raises
@@ -61,10 +61,9 @@ class AsyncAssertionBuilder:
61
61
  last_error: AssertionError | None = None
62
62
  while True:
63
63
  try:
64
- if inspect.iscoroutinefunction(self._func):
65
- val = await self._func()
66
- else:
67
- val = self._func()
64
+ val = self._func()
65
+ if inspect.isawaitable(val):
66
+ val = await val
68
67
  builder = self._builder_func(val, self._description)
69
68
  method = getattr(builder, name)
70
69
  method(*args, **kwargs)
@@ -53,8 +53,8 @@ class BaseMixin(_MixinBase):
53
53
  **kwargs: see below
54
54
 
55
55
  Keyword Args:
56
- ignore: the key/field (or list of keys/fields) to ignore
57
- include: the key/field (or list of keys/fields) to include
56
+ ignore: the key/field (or list/set/frozenset of keys/fields) to ignore
57
+ include: the key/field (or list/set/frozenset of keys/fields) to include
58
58
 
59
59
  Examples:
60
60
  Usage::
@@ -114,21 +114,21 @@ class BaseMixin(_MixinBase):
114
114
 
115
115
  Raises:
116
116
  AssertionError: if actual is **not** equal to expected
117
+ TypeError: if ``ignore``/``include`` is a one-shot or otherwise unsupported iterable, or is
118
+ used on a value that is neither dict-like nor has introspectable fields
117
119
 
118
120
  Tip:
119
121
  Using :meth:`is_equal_to` with a ``float`` val is just asking for trouble. Instead, you'll
120
- always want to use *fuzzy* numeric assertions like :meth:`~assertpy.numeric.NumericMixin.is_close_to`
121
- or :meth:`~assertpy.numeric.NumericMixin.is_between`.
122
+ always want to use *fuzzy* numeric assertions like :meth:`~assertpy2.numeric.NumericMixin.is_close_to`
123
+ or :meth:`~assertpy2.numeric.NumericMixin.is_between`.
122
124
 
123
125
  See Also:
124
- :meth:`~assertpy.string.StringMixin.is_equal_to_ignoring_case` - for case-insensitive string equality
126
+ :meth:`~assertpy2.string.StringMixin.is_equal_to_ignoring_case` - for case-insensitive string equality
125
127
  """
126
128
  ignore = kwargs.get("ignore")
127
129
  include = kwargs.get("include")
128
130
 
129
- if self._check_dict_like(self.val, check_values=False, return_as_bool=True) and self._check_dict_like(
130
- other, check_values=False, return_as_bool=True
131
- ):
131
+ if self._is_dict_like(self.val, check_values=False) and self._is_dict_like(other, check_values=False):
132
132
  if self._dict_not_equal(self.val, other, ignore=ignore, include=include):
133
133
  self._dict_err(self.val, other, ignore=ignore, include=include)
134
134
  elif ignore or include:
@@ -18,6 +18,12 @@ __tracebackhide__ = True
18
18
  class CollectionMixin(_MixinBase):
19
19
  """Collection assertions mixin."""
20
20
 
21
+ def _as_list(self) -> list[Any]:
22
+ """Returns val as a list, raising TypeError if val is not iterable."""
23
+ if not isinstance(self.val, collections.abc.Iterable):
24
+ raise TypeError("val is not iterable")
25
+ return list(self.val)
26
+
21
27
  def is_iterable(self) -> Self:
22
28
  """Asserts that val is iterable.
23
29
 
@@ -96,7 +102,7 @@ class CollectionMixin(_MixinBase):
96
102
  if is_mapping_like(self.val):
97
103
  superdict = {}
98
104
  for superset_index, superset in enumerate(supersets):
99
- self._check_dict_like(superset, check_values=False, name=f"arg #{superset_index + 1}")
105
+ self._require_dict_like(superset, check_values=False, name=f"arg #{superset_index + 1}")
100
106
  for key in superset:
101
107
  superdict.update({key: superset[key]})
102
108
 
@@ -107,7 +113,7 @@ class CollectionMixin(_MixinBase):
107
113
  missing.append({key: self.val[key]}) # bad val
108
114
  if missing:
109
115
  return self.error(
110
- f"Expected <{self.val}> to be subset of {self._fmt_items(superdict)}, "
116
+ f"Expected <{self.val}> to be subset of <{superdict}>, "
111
117
  f"but {self._fmt_items(missing)} {'was' if len(missing) == 1 else 'were'} missing."
112
118
  )
113
119
  else:
@@ -253,9 +259,7 @@ class CollectionMixin(_MixinBase):
253
259
  Raises:
254
260
  ValueError: if val is empty
255
261
  """
256
- if not isinstance(self.val, collections.abc.Iterable):
257
- raise TypeError("val is not iterable")
258
- items = list(self.val)
262
+ items = self._as_list()
259
263
  if not items:
260
264
  raise ValueError("Expected non-empty iterable, but was empty.")
261
265
  return self.builder(items[0], self.description, self.kind)
@@ -274,9 +278,7 @@ class CollectionMixin(_MixinBase):
274
278
  Raises:
275
279
  ValueError: if val is empty
276
280
  """
277
- if not isinstance(self.val, collections.abc.Iterable):
278
- raise TypeError("val is not iterable")
279
- items = list(self.val)
281
+ items = self._as_list()
280
282
  if not items:
281
283
  raise ValueError("Expected non-empty iterable, but was empty.")
282
284
  return self.builder(items[-1], self.description, self.kind)
@@ -298,9 +300,7 @@ class CollectionMixin(_MixinBase):
298
300
  Raises:
299
301
  IndexError: if index is out of range
300
302
  """
301
- if not isinstance(self.val, collections.abc.Iterable):
302
- raise TypeError("val is not iterable")
303
- items = list(self.val)
303
+ items = self._as_list()
304
304
  if index < 0 or index >= len(items):
305
305
  raise IndexError(f"Expected index {index} to be in range [0, {len(items)}), but was out of range.")
306
306
  return self.builder(items[index], self.description, self.kind)
@@ -319,9 +319,7 @@ class CollectionMixin(_MixinBase):
319
319
  Raises:
320
320
  ValueError: if val is empty or has more than one element
321
321
  """
322
- if not isinstance(self.val, collections.abc.Iterable):
323
- raise TypeError("val is not iterable")
324
- items = list(self.val)
322
+ items = self._as_list()
325
323
  if not items:
326
324
  raise ValueError("Expected iterable with single element, but was empty.")
327
325
  if len(items) > 1:
@@ -42,25 +42,31 @@ class ContainsMixin(_MixinBase):
42
42
  AssertionError: if val does **not** contain the item or items
43
43
 
44
44
  Tip:
45
- Use the :meth:`~assertpy.dict.DictMixin.contains_key` alias when working with
45
+ Use the :meth:`~assertpy2.dict.DictMixin.contains_key` alias when working with
46
46
  *dict-like* objects to be self-documenting.
47
47
 
48
48
  See Also:
49
- :meth:`~assertpy.string.StringMixin.contains_ignoring_case` - for case-insensitive string contains
49
+ :meth:`~assertpy2.string.StringMixin.contains_ignoring_case` - for case-insensitive string contains
50
50
  """
51
51
  if len(items) == 0:
52
52
  raise ValueError("one or more args must be given")
53
53
  elif len(items) == 1:
54
- if isinstance(items[0], Matcher):
55
- if not any(items[0].matches(value) for value in self.val):
54
+ item = items[0]
55
+ if isinstance(item, Matcher):
56
+ if not any(item.matches(value) for value in self.val):
57
+ diff = DiffResult(
58
+ kind="contains", entries=[DiffEntry(path="missing", actual=None, expected=item.describe())]
59
+ )
56
60
  return self.error(
57
- f"Expected <{self.val}> to contain item matching {items[0].describe()}, but did not."
61
+ f"Expected <{self.val}> to contain item matching {item.describe()}, but did not.",
62
+ diff=diff,
58
63
  )
59
- elif items[0] not in self.val:
60
- if self._check_dict_like(self.val, return_as_bool=True):
61
- return self.error(f"Expected <{self.val}> to contain key <{items[0]}>, but did not.")
64
+ elif item not in self.val:
65
+ diff = DiffResult(kind="contains", entries=[DiffEntry(path="missing", actual=None, expected=item)])
66
+ if self._is_dict_like(self.val):
67
+ return self.error(f"Expected <{self.val}> to contain key <{item}>, but did not.", diff=diff)
62
68
  else:
63
- return self.error(f"Expected <{self.val}> to contain item <{items[0]}>, but did not.")
69
+ return self.error(f"Expected <{self.val}> to contain item <{item}>, but did not.", diff=diff)
64
70
  else:
65
71
  missing = []
66
72
  for item in items:
@@ -80,10 +86,10 @@ class ContainsMixin(_MixinBase):
80
86
  DiffEntry(path="missing", actual=None, expected=missing_item) for missing_item in missing_desc
81
87
  ],
82
88
  )
83
- if self._check_dict_like(self.val, return_as_bool=True):
89
+ if self._is_dict_like(self.val):
84
90
  return self.error(
85
91
  f"Expected <{self.val}> to contain keys {self._fmt_items(items)}, but did not contain"
86
- f" key{'' if len(missing) == 0 else 's'} {self._fmt_items(missing_desc)}.",
92
+ f" key{'' if len(missing) == 1 else 's'} {self._fmt_items(missing_desc)}.",
87
93
  diff=diff,
88
94
  )
89
95
  else:
@@ -119,7 +125,7 @@ class ContainsMixin(_MixinBase):
119
125
  AssertionError: if val **does** contain the item or items
120
126
 
121
127
  Tip:
122
- Use the :meth:`~assertpy.dict.DictMixin.does_not_contain_key` alias when working with
128
+ Use the :meth:`~assertpy2.dict.DictMixin.does_not_contain_key` alias when working with
123
129
  *dict-like* objects to be self-documenting.
124
130
  """
125
131
  if len(items) == 0:
@@ -11,6 +11,12 @@ if TYPE_CHECKING:
11
11
  __tracebackhide__ = True
12
12
 
13
13
 
14
+ def _require_datetime(value: object, label: str) -> None:
15
+ """Raise ``TypeError`` unless *value* is exactly a :class:`datetime.datetime`."""
16
+ if not isinstance(value, datetime.datetime):
17
+ raise TypeError(f"{label} must be datetime, but was type <{type(value).__name__}>")
18
+
19
+
14
20
  class DateMixin(_MixinBase):
15
21
  """Date and time assertions mixin."""
16
22
 
@@ -37,13 +43,11 @@ class DateMixin(_MixinBase):
37
43
  AssertionError: if val is **not** before the given date
38
44
 
39
45
  See Also:
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` - also works with datetime
46
+ :meth:`~assertpy2.numeric.NumericMixin.is_less_than` - numeric assertion, but also works with datetime
47
+ :meth:`~assertpy2.numeric.NumericMixin.is_less_than_or_equal_to` - also works with datetime
42
48
  """
43
- if type(self.val) is not datetime.datetime:
44
- raise TypeError(f"val must be datetime, but was type <{type(self.val).__name__}>")
45
- if type(other) is not datetime.datetime:
46
- raise TypeError(f"given arg must be datetime, but was type <{type(other).__name__}>")
49
+ _require_datetime(self.val, "val")
50
+ _require_datetime(other, "given arg")
47
51
  if self.val >= other:
48
52
  return self.error(
49
53
  f"Expected <{self.val.strftime('%Y-%m-%d %H:%M:%S')}> to be before"
@@ -74,13 +78,11 @@ class DateMixin(_MixinBase):
74
78
  AssertionError: if val is **not** after the given date
75
79
 
76
80
  See Also:
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` - also works with datetime
81
+ :meth:`~assertpy2.numeric.NumericMixin.is_greater_than` - numeric assertion, but also works with datetime
82
+ :meth:`~assertpy2.numeric.NumericMixin.is_greater_than_or_equal_to` - also works with datetime
79
83
  """
80
- if type(self.val) is not datetime.datetime:
81
- raise TypeError(f"val must be datetime, but was type <{type(self.val).__name__}>")
82
- if type(other) is not datetime.datetime:
83
- raise TypeError(f"given arg must be datetime, but was type <{type(other).__name__}>")
84
+ _require_datetime(self.val, "val")
85
+ _require_datetime(other, "given arg")
84
86
  if self.val <= other:
85
87
  return self.error(
86
88
  f"Expected <{self.val.strftime('%Y-%m-%d %H:%M:%S')}> to be after"
@@ -111,10 +113,8 @@ class DateMixin(_MixinBase):
111
113
  Raises:
112
114
  AssertionError: if val is **not** before or equal to the given date
113
115
  """
114
- if type(self.val) is not datetime.datetime:
115
- raise TypeError(f"val must be datetime, but was type <{type(self.val).__name__}>")
116
- if type(other) is not datetime.datetime:
117
- raise TypeError(f"given arg must be datetime, but was type <{type(other).__name__}>")
116
+ _require_datetime(self.val, "val")
117
+ _require_datetime(other, "given arg")
118
118
  if self.val > other:
119
119
  return self.error(
120
120
  f"Expected <{self.val.strftime('%Y-%m-%d %H:%M:%S')}> to be before or equal to"
@@ -145,10 +145,8 @@ class DateMixin(_MixinBase):
145
145
  Raises:
146
146
  AssertionError: if val is **not** after or equal to the given date
147
147
  """
148
- if type(self.val) is not datetime.datetime:
149
- raise TypeError(f"val must be datetime, but was type <{type(self.val).__name__}>")
150
- if type(other) is not datetime.datetime:
151
- raise TypeError(f"given arg must be datetime, but was type <{type(other).__name__}>")
148
+ _require_datetime(self.val, "val")
149
+ _require_datetime(other, "given arg")
152
150
  if self.val < other:
153
151
  return self.error(
154
152
  f"Expected <{self.val.strftime('%Y-%m-%d %H:%M:%S')}> to be after or equal to"
@@ -178,10 +176,8 @@ class DateMixin(_MixinBase):
178
176
  Raises:
179
177
  AssertionError: if val is **not** equal to the given date to the second
180
178
  """
181
- if type(self.val) is not datetime.datetime:
182
- raise TypeError(f"val must be datetime, but was type <{type(self.val).__name__}>")
183
- if type(other) is not datetime.datetime:
184
- raise TypeError(f"given arg must be datetime, but was type <{type(other).__name__}>")
179
+ _require_datetime(self.val, "val")
180
+ _require_datetime(other, "given arg")
185
181
  if (
186
182
  self.val.date() != other.date()
187
183
  or self.val.hour != other.hour
@@ -216,10 +212,8 @@ class DateMixin(_MixinBase):
216
212
  Raises:
217
213
  AssertionError: if val is **not** equal to the given date to the minute
218
214
  """
219
- if type(self.val) is not datetime.datetime:
220
- raise TypeError(f"val must be datetime, but was type <{type(self.val).__name__}>")
221
- if type(other) is not datetime.datetime:
222
- raise TypeError(f"given arg must be datetime, but was type <{type(other).__name__}>")
215
+ _require_datetime(self.val, "val")
216
+ _require_datetime(other, "given arg")
223
217
  if self.val.date() != other.date() or self.val.hour != other.hour or self.val.minute != other.minute:
224
218
  return self.error(
225
219
  f"Expected <{self.val.strftime('%Y-%m-%d %H:%M')}> to be equal to"
@@ -249,10 +243,8 @@ class DateMixin(_MixinBase):
249
243
  Raises:
250
244
  AssertionError: if val is **not** equal to the given date ignoring time
251
245
  """
252
- if type(self.val) is not datetime.datetime:
253
- raise TypeError(f"val must be datetime, but was type <{type(self.val).__name__}>")
254
- if type(other) is not datetime.datetime:
255
- raise TypeError(f"given arg must be datetime, but was type <{type(other).__name__}>")
246
+ _require_datetime(self.val, "val")
247
+ _require_datetime(other, "given arg")
256
248
  if self.val.date() != other.date():
257
249
  return self.error(
258
250
  f"Expected <{self.val.strftime('%Y-%m-%d')}> to be equal to"
@@ -16,7 +16,7 @@ class DictMixin(_MixinBase):
16
16
  def contains_key(self, *keys) -> Self:
17
17
  """Asserts the val is a dict and contains the given key or keys.
18
18
 
19
- Alias for :meth:`~assertpy.contains.ContainsMixin.contains`.
19
+ Alias for :meth:`~assertpy2.contains.ContainsMixin.contains`.
20
20
 
21
21
  Checks if the dict contains the given key or keys using ``in`` operator.
22
22
 
@@ -35,13 +35,13 @@ class DictMixin(_MixinBase):
35
35
  Raises:
36
36
  AssertionError: if val does **not** contain the key or keys
37
37
  """
38
- self._check_dict_like(self.val, check_values=False, check_getitem=False)
38
+ self._require_dict_like(self.val, check_values=False, check_getitem=False)
39
39
  return self.contains(*keys)
40
40
 
41
41
  def does_not_contain_key(self, *keys) -> Self:
42
42
  """Asserts the val is a dict and does not contain the given key or keys.
43
43
 
44
- Alias for :meth:`~assertpy.contains.ContainsMixin.does_not_contain`.
44
+ Alias for :meth:`~assertpy2.contains.ContainsMixin.does_not_contain`.
45
45
 
46
46
  Checks if the dict excludes the given key or keys using ``in`` operator.
47
47
 
@@ -60,7 +60,7 @@ class DictMixin(_MixinBase):
60
60
  Raises:
61
61
  AssertionError: if val **does** contain the key or keys
62
62
  """
63
- self._check_dict_like(self.val, check_values=False, check_getitem=False)
63
+ self._require_dict_like(self.val, check_values=False, check_getitem=False)
64
64
  return self.does_not_contain(*keys)
65
65
 
66
66
  def contains_value(self, *values) -> Self:
@@ -83,7 +83,7 @@ class DictMixin(_MixinBase):
83
83
  Raises:
84
84
  AssertionError: if val does **not** contain the value or values
85
85
  """
86
- self._check_dict_like(self.val, check_getitem=False)
86
+ self._require_dict_like(self.val, check_getitem=False)
87
87
  if len(values) == 0:
88
88
  raise ValueError("one or more value args must be given")
89
89
  missing = [value for value in values if value not in self.val.values()]
@@ -114,7 +114,7 @@ class DictMixin(_MixinBase):
114
114
  Raises:
115
115
  AssertionError: if val **does** contain the value or values
116
116
  """
117
- self._check_dict_like(self.val, check_getitem=False)
117
+ self._require_dict_like(self.val, check_getitem=False)
118
118
  if len(values) == 0:
119
119
  raise ValueError("one or more value args must be given")
120
120
  else:
@@ -158,7 +158,7 @@ class DictMixin(_MixinBase):
158
158
  Raises:
159
159
  AssertionError: if val does **not** contain the entry or entries
160
160
  """
161
- self._check_dict_like(self.val, check_values=False)
161
+ self._require_dict_like(self.val, check_values=False)
162
162
  entries = list(args) + [{key: value} for key, value in kwargs.items()]
163
163
  if len(entries) == 0:
164
164
  raise ValueError("one or more entry args must be given")
@@ -206,7 +206,7 @@ class DictMixin(_MixinBase):
206
206
  Raises:
207
207
  AssertionError: if val **does** contain the entry or entries
208
208
  """
209
- self._check_dict_like(self.val, check_values=False)
209
+ self._require_dict_like(self.val, check_values=False)
210
210
  entries = list(args) + [{key: value} for key, value in kwargs.items()]
211
211
  if len(entries) == 0:
212
212
  raise ValueError("one or more entry args must be given")
@@ -12,7 +12,7 @@ class DynamicMixin(_MixinBase):
12
12
  """Dynamic assertions mixin.
13
13
 
14
14
  When testing attributes of an object (or the contents of a dict), the
15
- :meth:`~assertpy.base.BaseMixin.is_equal_to` assertion can be a bit verbose::
15
+ :meth:`~assertpy2.base.BaseMixin.is_equal_to` assertion can be a bit verbose::
16
16
 
17
17
  fred = Person('Fred', 'Smith')
18
18
 
@@ -28,7 +28,7 @@ class ExceptionMixin(_MixinBase):
28
28
  def raises(self, ex) -> Self:
29
29
  """Asserts that val is callable and set the expected exception.
30
30
 
31
- Just sets the expected exception, but never calls val, and therefore never failes. You must
31
+ Just sets the expected exception, but never calls val, and therefore never fails. You must
32
32
  chain to :meth:`~when_called_with` to invoke ``val()``.
33
33
 
34
34
  Args: