assertpy2 2.2.0__tar.gz → 2.3.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 (81) hide show
  1. {assertpy2-2.2.0 → assertpy2-2.3.1}/PKG-INFO +56 -2
  2. {assertpy2-2.2.0 → assertpy2-2.3.1}/README.md +51 -1
  3. {assertpy2-2.2.0 → assertpy2-2.3.1}/assertpy2/_typing.py +13 -0
  4. {assertpy2-2.2.0 → assertpy2-2.3.1}/assertpy2/assertpy.py +6 -6
  5. {assertpy2-2.2.0 → assertpy2-2.3.1}/assertpy2/async_assertions.py +2 -2
  6. {assertpy2-2.2.0 → assertpy2-2.3.1}/assertpy2/base.py +19 -17
  7. assertpy2-2.3.1/assertpy2/behave_matchers.py +85 -0
  8. {assertpy2-2.2.0 → assertpy2-2.3.1}/assertpy2/collection.py +9 -19
  9. {assertpy2-2.2.0 → assertpy2-2.3.1}/assertpy2/contains.py +22 -27
  10. {assertpy2-2.2.0 → assertpy2-2.3.1}/assertpy2/date.py +20 -20
  11. {assertpy2-2.2.0 → assertpy2-2.3.1}/assertpy2/dict.py +8 -8
  12. {assertpy2-2.2.0 → assertpy2-2.3.1}/assertpy2/dynamic.py +7 -7
  13. {assertpy2-2.2.0 → assertpy2-2.3.1}/assertpy2/errors.py +2 -2
  14. {assertpy2-2.2.0 → assertpy2-2.3.1}/assertpy2/exception.py +5 -9
  15. {assertpy2-2.2.0 → assertpy2-2.3.1}/assertpy2/extracting.py +4 -4
  16. {assertpy2-2.2.0 → assertpy2-2.3.1}/assertpy2/file.py +11 -11
  17. {assertpy2-2.2.0 → assertpy2-2.3.1}/assertpy2/helpers.py +61 -42
  18. {assertpy2-2.2.0 → assertpy2-2.3.1}/assertpy2/matchers.py +40 -42
  19. {assertpy2-2.2.0 → assertpy2-2.3.1}/assertpy2/numeric.py +33 -41
  20. assertpy2-2.3.1/assertpy2/pytest_plugin.py +115 -0
  21. {assertpy2-2.2.0 → assertpy2-2.3.1}/assertpy2/snapshot.py +1 -1
  22. {assertpy2-2.2.0 → assertpy2-2.3.1}/assertpy2/string.py +17 -17
  23. {assertpy2-2.2.0 → assertpy2-2.3.1}/docs/api.md +152 -4
  24. {assertpy2-2.2.0 → assertpy2-2.3.1}/pyproject.toml +6 -2
  25. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_async.py +5 -5
  26. assertpy2-2.3.1/tests/test_behave_matchers.py +108 -0
  27. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_class.py +3 -3
  28. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_dyn.py +3 -3
  29. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_errors.py +29 -1
  30. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_expected_exception.py +1 -2
  31. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_extensions.py +5 -5
  32. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_extracting.py +2 -2
  33. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_numbers.py +1 -1
  34. assertpy2-2.3.1/tests/test_pytest_plugin.py +383 -0
  35. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_readme.py +4 -4
  36. {assertpy2-2.2.0 → assertpy2-2.3.1}/uv.lock +116 -1
  37. assertpy2-2.2.0/assertpy2/pytest_plugin.py +0 -42
  38. assertpy2-2.2.0/tests/test_pytest_plugin.py +0 -154
  39. {assertpy2-2.2.0 → assertpy2-2.3.1}/.codecov.yml +0 -0
  40. {assertpy2-2.2.0 → assertpy2-2.3.1}/.github/dependabot.yml +0 -0
  41. {assertpy2-2.2.0 → assertpy2-2.3.1}/.github/workflows/ci.yml +0 -0
  42. {assertpy2-2.2.0 → assertpy2-2.3.1}/.github/workflows/codeql.yml +0 -0
  43. {assertpy2-2.2.0 → assertpy2-2.3.1}/.github/workflows/publish.yml +0 -0
  44. {assertpy2-2.2.0 → assertpy2-2.3.1}/.github/workflows/scorecard.yml +0 -0
  45. {assertpy2-2.2.0 → assertpy2-2.3.1}/.gitignore +0 -0
  46. {assertpy2-2.2.0 → assertpy2-2.3.1}/CONTRIBUTING.md +0 -0
  47. {assertpy2-2.2.0 → assertpy2-2.3.1}/LICENSE +0 -0
  48. {assertpy2-2.2.0 → assertpy2-2.3.1}/SECURITY.md +0 -0
  49. {assertpy2-2.2.0 → assertpy2-2.3.1}/assertpy2/__init__.py +0 -0
  50. {assertpy2-2.2.0 → assertpy2-2.3.1}/assertpy2/py.typed +0 -0
  51. {assertpy2-2.2.0 → assertpy2-2.3.1}/docs/logo-dark.svg +0 -0
  52. {assertpy2-2.2.0 → assertpy2-2.3.1}/docs/logo.svg +0 -0
  53. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_bool.py +0 -0
  54. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_collection.py +0 -0
  55. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_core.py +0 -0
  56. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_custom_dict.py +0 -0
  57. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_custom_list.py +0 -0
  58. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_datetime.py +0 -0
  59. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_description.py +0 -0
  60. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_dict.py +0 -0
  61. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_dict_compare.py +0 -0
  62. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_equals.py +0 -0
  63. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_fail.py +0 -0
  64. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_file.py +0 -0
  65. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_in.py +0 -0
  66. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_list.py +0 -0
  67. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_matchers.py +0 -0
  68. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_matchers_phase3.py +0 -0
  69. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_namedtuple.py +0 -0
  70. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_none.py +0 -0
  71. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_overloads.py +0 -0
  72. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_phase2.py +0 -0
  73. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_same_as.py +0 -0
  74. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_snapshots.py +0 -0
  75. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_soft.py +0 -0
  76. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_soft_fail.py +0 -0
  77. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_string.py +0 -0
  78. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_structural.py +0 -0
  79. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_traceback.py +0 -0
  80. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_type.py +0 -0
  81. {assertpy2-2.2.0 → assertpy2-2.3.1}/tests/test_warn.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertpy2
3
- Version: 2.2.0
3
+ Version: 2.3.1
4
4
  Summary: Fluent assertion library for Python with full type safety and soft assertions
5
5
  Project-URL: Homepage, https://github.com/Solganis/assertpy2
6
6
  Project-URL: Repository, https://github.com/Solganis/assertpy2
@@ -27,6 +27,10 @@ Classifier: Topic :: Software Development
27
27
  Classifier: Topic :: Software Development :: Testing
28
28
  Requires-Python: >=3.10
29
29
  Requires-Dist: typing-extensions>=4.0
30
+ Provides-Extra: allure
31
+ Requires-Dist: allure-pytest>=2.13; extra == 'allure'
32
+ Provides-Extra: behave
33
+ Requires-Dist: behave>=1.2.6; extra == 'behave'
30
34
  Description-Content-Type: text/markdown
31
35
 
32
36
  <p align="center">
@@ -126,7 +130,7 @@ FAILED test_example.py::test_comparison
126
130
  | **Async assertions** | No | No | No | **eventually() with polling** |
127
131
  | **Soft assertions** | No | No | Yes (not thread-safe) | **Yes (thread-safe, async-safe)** |
128
132
  | **Structured errors** | Rewrite only | Mismatch string | String only | **.actual .expected .diff** |
129
- | **Maintained** | N/A | Minimal | 2020 | **Active (2026)** |
133
+ | **Maintained** | N/A | Minimal | 2020 | **Active** |
130
134
 
131
135
  </div>
132
136
 
@@ -167,6 +171,8 @@ assert_that(items).is_type_of(list).is_length(3).contains("admin")
167
171
  - **Extracting**: flatten collections on attributes with `filter` and `sort` support.
168
172
  - **File assertions**: `exists()`, `is_file()`, `is_readable()`, `is_writable()`, `is_executable()` with `pathlib.Path` support.
169
173
  - **Snapshot testing**: store and compare data structures in JSON format, inspired by Jest.
174
+ - **Allure integration**: auto-attach structured diff and actual/expected data to Allure reports.
175
+ - **Behave step matchers**: ready-made parameter types (`PositiveInt`, `BoolLike`, etc.) for Behave step definitions.
170
176
  - **Extensions**: add custom assertions via `add_extension()`.
171
177
  - Strings, numbers, lists, tuples, sets, dicts, dates, booleans, objects, exceptions.
172
178
 
@@ -360,6 +366,54 @@ assert_that(5).is_5()
360
366
  See the [full API reference](docs/api.md) for all assertion methods, examples, and advanced features.
361
367
 
362
368
 
369
+ ## Allure integration
370
+
371
+ When `allure-pytest` is installed, the pytest plugin auto-attaches structured failure data to Allure reports as JSON attachments.
372
+
373
+ ```bash
374
+ pip install assertpy2[allure]
375
+ ```
376
+
377
+ Three modes controlled via `pytest.ini` (or `pyproject.toml`):
378
+
379
+ | Mode | What is attached |
380
+ |---|---|
381
+ | `diff` (default) | Structured Diff JSON (path-level breakdown) |
382
+ | `full` | Structured Diff + actual/expected JSON |
383
+ | `off` | Nothing |
384
+
385
+ ```toml
386
+ # pyproject.toml
387
+ [tool.pytest.ini_options]
388
+ assertpy2_allure = "full"
389
+ ```
390
+
391
+
392
+ ## Behave step matchers
393
+
394
+ Ready-made parameter types for Behave step definitions:
395
+
396
+ ```bash
397
+ pip install assertpy2[behave]
398
+ ```
399
+
400
+ ```py
401
+ # in environment.py or steps/conftest.py
402
+ from assertpy2.behave_matchers import register_assertpy_types
403
+ register_assertpy_types()
404
+ ```
405
+
406
+ Then use in step definitions:
407
+
408
+ ```py
409
+ @given('a user aged {age:PositiveInt}')
410
+ def step_impl(context, age):
411
+ context.age = age # already validated as int > 0
412
+ ```
413
+
414
+ Available types: `PositiveInt`, `NonNegativeInt`, `PositiveFloat`, `NonEmptyString`, `BoolLike`.
415
+
416
+
363
417
  ## Migration from assertpy
364
418
 
365
419
  assertpy2 is a drop-in replacement for Python 3.10+. Change the import, everything else works:
@@ -95,7 +95,7 @@ FAILED test_example.py::test_comparison
95
95
  | **Async assertions** | No | No | No | **eventually() with polling** |
96
96
  | **Soft assertions** | No | No | Yes (not thread-safe) | **Yes (thread-safe, async-safe)** |
97
97
  | **Structured errors** | Rewrite only | Mismatch string | String only | **.actual .expected .diff** |
98
- | **Maintained** | N/A | Minimal | 2020 | **Active (2026)** |
98
+ | **Maintained** | N/A | Minimal | 2020 | **Active** |
99
99
 
100
100
  </div>
101
101
 
@@ -136,6 +136,8 @@ assert_that(items).is_type_of(list).is_length(3).contains("admin")
136
136
  - **Extracting**: flatten collections on attributes with `filter` and `sort` support.
137
137
  - **File assertions**: `exists()`, `is_file()`, `is_readable()`, `is_writable()`, `is_executable()` with `pathlib.Path` support.
138
138
  - **Snapshot testing**: store and compare data structures in JSON format, inspired by Jest.
139
+ - **Allure integration**: auto-attach structured diff and actual/expected data to Allure reports.
140
+ - **Behave step matchers**: ready-made parameter types (`PositiveInt`, `BoolLike`, etc.) for Behave step definitions.
139
141
  - **Extensions**: add custom assertions via `add_extension()`.
140
142
  - Strings, numbers, lists, tuples, sets, dicts, dates, booleans, objects, exceptions.
141
143
 
@@ -329,6 +331,54 @@ assert_that(5).is_5()
329
331
  See the [full API reference](docs/api.md) for all assertion methods, examples, and advanced features.
330
332
 
331
333
 
334
+ ## Allure integration
335
+
336
+ When `allure-pytest` is installed, the pytest plugin auto-attaches structured failure data to Allure reports as JSON attachments.
337
+
338
+ ```bash
339
+ pip install assertpy2[allure]
340
+ ```
341
+
342
+ Three modes controlled via `pytest.ini` (or `pyproject.toml`):
343
+
344
+ | Mode | What is attached |
345
+ |---|---|
346
+ | `diff` (default) | Structured Diff JSON (path-level breakdown) |
347
+ | `full` | Structured Diff + actual/expected JSON |
348
+ | `off` | Nothing |
349
+
350
+ ```toml
351
+ # pyproject.toml
352
+ [tool.pytest.ini_options]
353
+ assertpy2_allure = "full"
354
+ ```
355
+
356
+
357
+ ## Behave step matchers
358
+
359
+ Ready-made parameter types for Behave step definitions:
360
+
361
+ ```bash
362
+ pip install assertpy2[behave]
363
+ ```
364
+
365
+ ```py
366
+ # in environment.py or steps/conftest.py
367
+ from assertpy2.behave_matchers import register_assertpy_types
368
+ register_assertpy_types()
369
+ ```
370
+
371
+ Then use in step definitions:
372
+
373
+ ```py
374
+ @given('a user aged {age:PositiveInt}')
375
+ def step_impl(context, age):
376
+ context.age = age # already validated as int > 0
377
+ ```
378
+
379
+ Available types: `PositiveInt`, `NonNegativeInt`, `PositiveFloat`, `NonEmptyString`, `BoolLike`.
380
+
381
+
332
382
  ## Migration from assertpy
333
383
 
334
384
  assertpy2 is a drop-in replacement for Python 3.10+. Change the import, everything else works:
@@ -66,6 +66,19 @@ if TYPE_CHECKING:
66
66
  def contains_in_order(self, *items: object) -> Self: ...
67
67
  def is_empty(self) -> Self: ...
68
68
  def is_not_empty(self) -> Self: ...
69
+ # StringMixin - regex
70
+ def extracting_group(self, pattern: str, group: int | str = ...) -> Self: ...
71
+ def matches_with_groups(self, pattern: str) -> Self: ...
72
+ # FileMixin
73
+ def exists(self) -> Self: ...
74
+ def does_not_exist(self) -> Self: ...
75
+ def is_file(self) -> Self: ...
76
+ def is_directory(self) -> Self: ...
77
+ def is_named(self, filename: str) -> Self: ...
78
+ def is_child_of(self, parent: object) -> Self: ...
79
+ def is_readable(self) -> Self: ...
80
+ def is_writable(self) -> Self: ...
81
+ def is_executable(self) -> Self: ...
69
82
 
70
83
  class _NumericAssertion(_CoreAssertion, Protocol):
71
84
  """Assertions available for ``int``, ``float``, and ``complex`` values."""
@@ -73,7 +73,7 @@ from .numeric import NumericMixin
73
73
  from .snapshot import SnapshotMixin
74
74
  from .string import StringMixin
75
75
 
76
- __version__ = "2.1.4"
76
+ __version__ = "2.3.1"
77
77
 
78
78
  __tracebackhide__ = True # clean tracebacks via py.test integration
79
79
  contextlib.__tracebackhide__ = True # monkey patch contextlib with clean py.test tracebacks
@@ -160,7 +160,7 @@ def soft_assertions() -> Iterator[None]:
160
160
 
161
161
  errs = _soft_err.get([])
162
162
  if errs and _soft_ctx.get() == 0:
163
- out = "soft assertion failures:\n" + "\n".join("%d. %s" % (i + 1, msg) for i, msg in enumerate(errs))
163
+ out = "soft assertion failures:\n" + "\n".join(f"{i + 1}. {msg}" for i, msg in enumerate(errs))
164
164
  _soft_err.set([])
165
165
  raise AssertionError(out)
166
166
 
@@ -300,7 +300,7 @@ def fail(msg=""):
300
300
  except TypeError as e:
301
301
  assert_that(str(e)).contains('unsupported operand')
302
302
  """
303
- raise AssertionError("Fail: %s!" % msg if msg else "Fail!")
303
+ raise AssertionError(f"Fail: {msg}!" if msg else "Fail!")
304
304
 
305
305
 
306
306
  def soft_fail(msg=""):
@@ -332,7 +332,7 @@ def soft_fail(msg=""):
332
332
 
333
333
  """
334
334
  if _soft_ctx.get():
335
- _soft_err.get().append("Fail: %s!" % msg if msg else "Fail!")
335
+ _soft_err.get().append(f"Fail: {msg}!" if msg else "Fail!")
336
336
  return
337
337
  fail(msg)
338
338
 
@@ -427,7 +427,7 @@ class WarningLoggingAdapter(logging.LoggerAdapter):
427
427
  prev = frame
428
428
 
429
429
  filename, lineno = _unwind(inspect.currentframe())
430
- return "[%s:%d]: %s" % (os.path.basename(filename), lineno, msg), kwargs
430
+ return f"[{os.path.basename(filename)}:{lineno}]: {msg}", kwargs
431
431
 
432
432
 
433
433
  _logger = logging.getLogger("assertpy2")
@@ -512,7 +512,7 @@ class AssertionBuilder(
512
512
  AssertionBuilder: returns this instance to chain to the next assertion, but only when
513
513
  ``AssertionError`` is not raised, as is the case when ``kind`` is ``warn`` or ``soft``.
514
514
  """
515
- out = "%s%s" % ("[%s] " % self.description if len(self.description) > 0 else "", msg)
515
+ out = f"{f'[{self.description}] ' if len(self.description) > 0 else ''}{msg}"
516
516
  if self.kind == "warn":
517
517
  self.logger.warning(out)
518
518
  return self
@@ -73,8 +73,8 @@ class AsyncAssertionBuilder:
73
73
  last_error = exc
74
74
  if loop.time() >= deadline:
75
75
  raise AssertionError(
76
- "Expected condition not met after %.1f seconds. Last failure: %s"
77
- % (self._timeout, last_error)
76
+ f"Expected condition not met after {self._timeout:.1f} seconds."
77
+ f" Last failure: {last_error}"
78
78
  ) from last_error
79
79
  await asyncio.sleep(self._interval)
80
80
 
@@ -31,6 +31,7 @@ from __future__ import annotations
31
31
  import collections.abc
32
32
  from typing import TYPE_CHECKING
33
33
 
34
+ from .errors import DiffEntry, DiffResult
34
35
  from .matchers import Matcher, StructureMatcher
35
36
 
36
37
  if TYPE_CHECKING:
@@ -135,9 +136,10 @@ class BaseMixin:
135
136
  else:
136
137
  if self.val != other:
137
138
  return self.error(
138
- "Expected <%s> to be equal to <%s>, but was not." % (self.val, other),
139
+ f"Expected <{self.val}> to be equal to <{other}>, but was not.",
139
140
  actual=self.val,
140
141
  expected=other,
142
+ diff=DiffResult(kind="scalar", entries=[DiffEntry(path=".", actual=self.val, expected=other)]),
141
143
  )
142
144
  return self
143
145
 
@@ -168,10 +170,10 @@ class BaseMixin:
168
170
  """
169
171
  if isinstance(matcher, Matcher):
170
172
  if not matcher.matches(self.val):
171
- return self.error("Expected %s, but %s." % (matcher.describe(), matcher.describe_mismatch(self.val)))
173
+ return self.error(f"Expected {matcher.describe()}, but {matcher.describe_mismatch(self.val)}.")
172
174
  elif callable(matcher):
173
175
  if not matcher(self.val):
174
- return self.error("Expected <%s> to satisfy <%s>, but did not." % (self.val, matcher))
176
+ return self.error(f"Expected <{self.val}> to satisfy <{matcher}>, but did not.")
175
177
  else:
176
178
  raise TypeError("given arg must be a Matcher or callable")
177
179
  return self
@@ -207,14 +209,14 @@ class BaseMixin:
207
209
  for i, item in enumerate(self.val):
208
210
  if not matcher.matches(item):
209
211
  return self.error(
210
- "Expected all items to satisfy %s, but item at index %d <%s> did not: %s."
211
- % (matcher.describe(), i, item, matcher.describe_mismatch(item))
212
+ f"Expected all items to satisfy {matcher.describe()}, but item at index {i} <{item}> did not:"
213
+ f" {matcher.describe_mismatch(item)}."
212
214
  )
213
215
  elif callable(matcher):
214
216
  for i, item in enumerate(self.val):
215
217
  if not matcher(item):
216
218
  return self.error(
217
- "Expected all items to satisfy <%s>, but item at index %d <%s> did not." % (matcher, i, item)
219
+ f"Expected all items to satisfy <{matcher}>, but item at index {i} <{item}> did not."
218
220
  )
219
221
  else:
220
222
  raise TypeError("given arg must be a Matcher or callable")
@@ -255,8 +257,8 @@ class BaseMixin:
255
257
  matcher = StructureMatcher(spec)
256
258
  if not matcher.matches(self.val):
257
259
  return self.error(
258
- "Expected <%s> to match structure %s, but %s."
259
- % (self.val, matcher.describe(), matcher.describe_mismatch(self.val))
260
+ f"Expected <{self.val}> to match structure {matcher.describe()}, but"
261
+ f" {matcher.describe_mismatch(self.val)}."
260
262
  )
261
263
  return self
262
264
 
@@ -425,7 +427,7 @@ class BaseMixin:
425
427
  AssertionError: if actual **is** equal to expected
426
428
  """
427
429
  if self.val == other:
428
- return self.error("Expected <%s> to be not equal to <%s>, but was." % (self.val, other))
430
+ return self.error(f"Expected <{self.val}> to be not equal to <{other}>, but was.")
429
431
  return self
430
432
 
431
433
  def is_same_as(self, other) -> Self:
@@ -467,7 +469,7 @@ class BaseMixin:
467
469
  AssertionError: if actual is **not** identical to expected
468
470
  """
469
471
  if self.val is not other:
470
- return self.error("Expected <%s> to be identical to <%s>, but was not." % (self.val, other))
472
+ return self.error(f"Expected <{self.val}> to be identical to <{other}>, but was not.")
471
473
  return self
472
474
 
473
475
  def is_not_same_as(self, other) -> Self:
@@ -498,7 +500,7 @@ class BaseMixin:
498
500
  AssertionError: if actual **is** identical to expected
499
501
  """
500
502
  if self.val is other:
501
- return self.error("Expected <%s> to be not identical to <%s>, but was." % (self.val, other))
503
+ return self.error(f"Expected <{self.val}> to be not identical to <{other}>, but was.")
502
504
  return self
503
505
 
504
506
  def is_true(self) -> Self:
@@ -523,7 +525,7 @@ class BaseMixin:
523
525
  AssertionError: if val **is** false
524
526
  """
525
527
  if not self.val:
526
- return self.error("Expected <%s> to be <True>, but was not." % self.val)
528
+ return self.error(f"Expected <{self.val}> to be <True>, but was not.")
527
529
  return self
528
530
 
529
531
  def is_false(self) -> Self:
@@ -548,7 +550,7 @@ class BaseMixin:
548
550
  AssertionError: if val **is** true
549
551
  """
550
552
  if self.val:
551
- return self.error("Expected <%s> to be <False>, but was not." % self.val)
553
+ return self.error(f"Expected <{self.val}> to be <False>, but was not.")
552
554
  return self
553
555
 
554
556
  def is_none(self) -> Self:
@@ -567,7 +569,7 @@ class BaseMixin:
567
569
  AssertionError: if val is **not** none
568
570
  """
569
571
  if self.val is not None:
570
- return self.error("Expected <%s> to be <None>, but was not." % self.val)
572
+ return self.error(f"Expected <{self.val}> to be <None>, but was not.")
571
573
  return self
572
574
 
573
575
  def is_not_none(self) -> Self:
@@ -623,7 +625,7 @@ class BaseMixin:
623
625
  raise TypeError("given arg must be a type")
624
626
  if type(self.val) is not some_type:
625
627
  t = self._type(self.val)
626
- return self.error("Expected <%s:%s> to be of type <%s>, but was not." % (self.val, t, some_type.__name__))
628
+ return self.error(f"Expected <{self.val}:{t}> to be of type <{some_type.__name__}>, but was not.")
627
629
  return self
628
630
 
629
631
  def is_instance_of(self, some_class) -> Self:
@@ -661,7 +663,7 @@ class BaseMixin:
661
663
  if not isinstance(self.val, some_class):
662
664
  t = self._type(self.val)
663
665
  return self.error(
664
- "Expected <%s:%s> to be instance of class <%s>, but was not." % (self.val, t, some_class.__name__)
666
+ f"Expected <{self.val}:{t}> to be instance of class <{some_class.__name__}>, but was not."
665
667
  )
666
668
  except TypeError:
667
669
  raise TypeError("given arg must be a class") from None
@@ -695,5 +697,5 @@ class BaseMixin:
695
697
  if length < 0:
696
698
  raise ValueError("given arg must be a positive int")
697
699
  if len(self.val) != length:
698
- return self.error("Expected <%s> to be of length <%d>, but was <%d>." % (self.val, length, len(self.val)))
700
+ return self.error(f"Expected <{self.val}> to be of length <{length}>, but was <{len(self.val)}>.")
699
701
  return self
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ _BOOL_TRUE = frozenset({"true", "yes", "1", "on"})
4
+ _BOOL_FALSE = frozenset({"false", "no", "0", "off"})
5
+
6
+
7
+ def _with_pattern(pattern):
8
+ def decorator(func):
9
+ func.pattern = pattern
10
+ return func
11
+
12
+ return decorator
13
+
14
+
15
+ @_with_pattern(r"\d+")
16
+ def _positive_int(text):
17
+ value = int(text)
18
+ if value <= 0:
19
+ raise ValueError(f"expected positive integer, got {value}")
20
+ return value
21
+
22
+
23
+ @_with_pattern(r"\d+")
24
+ def _non_negative_int(text):
25
+ return int(text)
26
+
27
+
28
+ @_with_pattern(r"\d+\.?\d*")
29
+ def _positive_float(text):
30
+ value = float(text)
31
+ if value <= 0:
32
+ raise ValueError(f"expected positive float, got {value}")
33
+ return value
34
+
35
+
36
+ @_with_pattern(r".+?")
37
+ def _non_empty_string(text):
38
+ stripped = text.strip()
39
+ if not stripped:
40
+ raise ValueError("expected non-empty string, got blank")
41
+ return stripped
42
+
43
+
44
+ @_with_pattern(r"\w+")
45
+ def _bool_like(text):
46
+ lower = text.strip().lower()
47
+ if lower in _BOOL_TRUE:
48
+ return True
49
+ if lower in _BOOL_FALSE:
50
+ return False
51
+ raise ValueError(f"expected boolean-like value, got {text!r}")
52
+
53
+
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
+ }
61
+
62
+
63
+ def register_assertpy_types() -> None:
64
+ """Register assertpy2 parameter types for Behave step definitions.
65
+
66
+ Registers the following types for use in step patterns:
67
+
68
+ - ``{param:PositiveInt}`` - positive integer (> 0)
69
+ - ``{param:NonNegativeInt}`` - non-negative integer (>= 0)
70
+ - ``{param:PositiveFloat}`` - positive float (> 0)
71
+ - ``{param:NonEmptyString}`` - non-empty, stripped string
72
+ - ``{param:BoolLike}`` - boolean from true/false/yes/no/1/0/on/off
73
+
74
+ Requires ``behave`` to be installed (``pip install assertpy2[behave]``).
75
+
76
+ Raises:
77
+ ImportError: if behave is not installed
78
+ """
79
+ try:
80
+ from behave import register_type
81
+ except ImportError:
82
+ raise ImportError(
83
+ "behave is required for register_assertpy_types(). Install it with: pip install assertpy2[behave]"
84
+ ) from None
85
+ register_type(**ASSERTPY_TYPES)
@@ -119,7 +119,7 @@ class CollectionMixin:
119
119
  # flatten superset dicts
120
120
  superdict = {}
121
121
  for idx, j in enumerate(supersets):
122
- self._check_dict_like(j, check_values=False, name="arg #%d" % (idx + 1))
122
+ self._check_dict_like(j, check_values=False, name=f"arg #{idx + 1}")
123
123
  for k in j:
124
124
  superdict.update({k: j[k]})
125
125
 
@@ -130,13 +130,8 @@ class CollectionMixin:
130
130
  missing.append({i: self.val[i]}) # bad val
131
131
  if missing:
132
132
  return self.error(
133
- "Expected <%s> to be subset of %s, but %s %s missing."
134
- % (
135
- self.val,
136
- self._fmt_items(superdict),
137
- self._fmt_items(missing),
138
- "was" if len(missing) == 1 else "were",
139
- )
133
+ f"Expected <{self.val}> to be subset of {self._fmt_items(superdict)}, "
134
+ f"but {self._fmt_items(missing)} {'was' if len(missing) == 1 else 'were'} missing."
140
135
  )
141
136
  else:
142
137
  # flatten supersets
@@ -153,13 +148,8 @@ class CollectionMixin:
153
148
  missing.append(i)
154
149
  if missing:
155
150
  return self.error(
156
- "Expected <%s> to be subset of %s, but %s %s missing."
157
- % (
158
- self.val,
159
- self._fmt_items(superset),
160
- self._fmt_items(missing),
161
- "was" if len(missing) == 1 else "were",
162
- )
151
+ f"Expected <{self.val}> to be subset of {self._fmt_items(superset)}, "
152
+ f"but {self._fmt_items(missing)} {'was' if len(missing) == 1 else 'were'} missing."
163
153
  )
164
154
 
165
155
  return self
@@ -203,14 +193,14 @@ class CollectionMixin:
203
193
  if reverse:
204
194
  if key(x) > key(prev):
205
195
  return self.error(
206
- "Expected <%s> to be sorted reverse, but subset %s at index %s is not."
207
- % (self.val, self._fmt_items([prev, x]), i - 1)
196
+ f"Expected <{self.val}> to be sorted reverse, "
197
+ f"but subset {self._fmt_items([prev, x])} at index {i - 1} is not."
208
198
  )
209
199
  else:
210
200
  if key(x) < key(prev):
211
201
  return self.error(
212
- "Expected <%s> to be sorted, but subset %s at index %s is not."
213
- % (self.val, self._fmt_items([prev, x]), i - 1)
202
+ f"Expected <{self.val}> to be sorted, "
203
+ f"but subset {self._fmt_items([prev, x])} at index {i - 1} is not."
214
204
  )
215
205
  prev = x
216
206