assertpy2 2.2.0__tar.gz → 2.3.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 (81) hide show
  1. {assertpy2-2.2.0 → assertpy2-2.3.0}/PKG-INFO +56 -2
  2. {assertpy2-2.2.0 → assertpy2-2.3.0}/README.md +51 -1
  3. {assertpy2-2.2.0 → assertpy2-2.3.0}/assertpy2/assertpy.py +5 -5
  4. {assertpy2-2.2.0 → assertpy2-2.3.0}/assertpy2/async_assertions.py +2 -2
  5. {assertpy2-2.2.0 → assertpy2-2.3.0}/assertpy2/base.py +19 -17
  6. assertpy2-2.3.0/assertpy2/behave_matchers.py +85 -0
  7. {assertpy2-2.2.0 → assertpy2-2.3.0}/assertpy2/collection.py +9 -19
  8. {assertpy2-2.2.0 → assertpy2-2.3.0}/assertpy2/contains.py +22 -27
  9. {assertpy2-2.2.0 → assertpy2-2.3.0}/assertpy2/date.py +20 -20
  10. {assertpy2-2.2.0 → assertpy2-2.3.0}/assertpy2/dict.py +8 -8
  11. {assertpy2-2.2.0 → assertpy2-2.3.0}/assertpy2/dynamic.py +7 -7
  12. {assertpy2-2.2.0 → assertpy2-2.3.0}/assertpy2/errors.py +2 -2
  13. {assertpy2-2.2.0 → assertpy2-2.3.0}/assertpy2/exception.py +5 -9
  14. {assertpy2-2.2.0 → assertpy2-2.3.0}/assertpy2/extracting.py +4 -4
  15. {assertpy2-2.2.0 → assertpy2-2.3.0}/assertpy2/file.py +11 -11
  16. {assertpy2-2.2.0 → assertpy2-2.3.0}/assertpy2/helpers.py +61 -42
  17. {assertpy2-2.2.0 → assertpy2-2.3.0}/assertpy2/matchers.py +40 -42
  18. {assertpy2-2.2.0 → assertpy2-2.3.0}/assertpy2/numeric.py +33 -41
  19. assertpy2-2.3.0/assertpy2/pytest_plugin.py +115 -0
  20. {assertpy2-2.2.0 → assertpy2-2.3.0}/assertpy2/snapshot.py +1 -1
  21. {assertpy2-2.2.0 → assertpy2-2.3.0}/assertpy2/string.py +17 -17
  22. {assertpy2-2.2.0 → assertpy2-2.3.0}/docs/api.md +152 -4
  23. {assertpy2-2.2.0 → assertpy2-2.3.0}/pyproject.toml +6 -2
  24. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_async.py +5 -5
  25. assertpy2-2.3.0/tests/test_behave_matchers.py +108 -0
  26. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_class.py +3 -3
  27. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_dyn.py +3 -3
  28. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_errors.py +29 -1
  29. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_expected_exception.py +1 -2
  30. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_extensions.py +5 -5
  31. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_extracting.py +2 -2
  32. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_numbers.py +1 -1
  33. assertpy2-2.3.0/tests/test_pytest_plugin.py +383 -0
  34. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_readme.py +4 -4
  35. {assertpy2-2.2.0 → assertpy2-2.3.0}/uv.lock +116 -1
  36. assertpy2-2.2.0/assertpy2/pytest_plugin.py +0 -42
  37. assertpy2-2.2.0/tests/test_pytest_plugin.py +0 -154
  38. {assertpy2-2.2.0 → assertpy2-2.3.0}/.codecov.yml +0 -0
  39. {assertpy2-2.2.0 → assertpy2-2.3.0}/.github/dependabot.yml +0 -0
  40. {assertpy2-2.2.0 → assertpy2-2.3.0}/.github/workflows/ci.yml +0 -0
  41. {assertpy2-2.2.0 → assertpy2-2.3.0}/.github/workflows/codeql.yml +0 -0
  42. {assertpy2-2.2.0 → assertpy2-2.3.0}/.github/workflows/publish.yml +0 -0
  43. {assertpy2-2.2.0 → assertpy2-2.3.0}/.github/workflows/scorecard.yml +0 -0
  44. {assertpy2-2.2.0 → assertpy2-2.3.0}/.gitignore +0 -0
  45. {assertpy2-2.2.0 → assertpy2-2.3.0}/CONTRIBUTING.md +0 -0
  46. {assertpy2-2.2.0 → assertpy2-2.3.0}/LICENSE +0 -0
  47. {assertpy2-2.2.0 → assertpy2-2.3.0}/SECURITY.md +0 -0
  48. {assertpy2-2.2.0 → assertpy2-2.3.0}/assertpy2/__init__.py +0 -0
  49. {assertpy2-2.2.0 → assertpy2-2.3.0}/assertpy2/_typing.py +0 -0
  50. {assertpy2-2.2.0 → assertpy2-2.3.0}/assertpy2/py.typed +0 -0
  51. {assertpy2-2.2.0 → assertpy2-2.3.0}/docs/logo-dark.svg +0 -0
  52. {assertpy2-2.2.0 → assertpy2-2.3.0}/docs/logo.svg +0 -0
  53. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_bool.py +0 -0
  54. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_collection.py +0 -0
  55. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_core.py +0 -0
  56. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_custom_dict.py +0 -0
  57. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_custom_list.py +0 -0
  58. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_datetime.py +0 -0
  59. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_description.py +0 -0
  60. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_dict.py +0 -0
  61. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_dict_compare.py +0 -0
  62. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_equals.py +0 -0
  63. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_fail.py +0 -0
  64. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_file.py +0 -0
  65. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_in.py +0 -0
  66. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_list.py +0 -0
  67. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_matchers.py +0 -0
  68. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_matchers_phase3.py +0 -0
  69. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_namedtuple.py +0 -0
  70. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_none.py +0 -0
  71. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_overloads.py +0 -0
  72. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_phase2.py +0 -0
  73. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_same_as.py +0 -0
  74. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_snapshots.py +0 -0
  75. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_soft.py +0 -0
  76. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_soft_fail.py +0 -0
  77. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_string.py +0 -0
  78. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_structural.py +0 -0
  79. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_traceback.py +0 -0
  80. {assertpy2-2.2.0 → assertpy2-2.3.0}/tests/test_type.py +0 -0
  81. {assertpy2-2.2.0 → assertpy2-2.3.0}/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.0
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:
@@ -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
 
@@ -80,13 +80,13 @@ class ContainsMixin:
80
80
  if isinstance(items[0], Matcher):
81
81
  if not any(items[0].matches(v) for v in self.val):
82
82
  return self.error(
83
- "Expected <%s> to contain item matching %s, but did not." % (self.val, items[0].describe())
83
+ f"Expected <{self.val}> to contain item matching {items[0].describe()}, but did not."
84
84
  )
85
85
  elif items[0] not in self.val:
86
86
  if self._check_dict_like(self.val, return_as_bool=True):
87
- return self.error("Expected <%s> to contain key <%s>, but did not." % (self.val, items[0]))
87
+ return self.error(f"Expected <{self.val}> to contain key <{items[0]}>, but did not.")
88
88
  else:
89
- return self.error("Expected <%s> to contain item <%s>, but did not." % (self.val, items[0]))
89
+ return self.error(f"Expected <{self.val}> to contain item <{items[0]}>, but did not.")
90
90
  else:
91
91
  missing = []
92
92
  for i in items:
@@ -99,18 +99,13 @@ class ContainsMixin:
99
99
  missing_desc = [m.describe() if isinstance(m, Matcher) else m for m in missing]
100
100
  if self._check_dict_like(self.val, return_as_bool=True):
101
101
  return self.error(
102
- "Expected <%s> to contain keys %s, but did not contain key%s %s."
103
- % (
104
- self.val,
105
- self._fmt_items(items),
106
- "" if len(missing) == 0 else "s",
107
- self._fmt_items(missing_desc),
108
- )
102
+ f"Expected <{self.val}> to contain keys {self._fmt_items(items)}, but did not contain"
103
+ f" key{'' if len(missing) == 0 else 's'} {self._fmt_items(missing_desc)}."
109
104
  )
110
105
  else:
111
106
  return self.error(
112
- "Expected <%s> to contain items %s, but did not contain %s."
113
- % (self.val, self._fmt_items(items), self._fmt_items(missing_desc))
107
+ f"Expected <{self.val}> to contain items {self._fmt_items(items)},"
108
+ f" but did not contain {self._fmt_items(missing_desc)}."
114
109
  )
115
110
  return self
116
111
 
@@ -146,7 +141,7 @@ class ContainsMixin:
146
141
  raise ValueError("one or more args must be given")
147
142
  elif len(items) == 1:
148
143
  if items[0] in self.val:
149
- return self.error("Expected <%s> to not contain item <%s>, but did." % (self.val, items[0]))
144
+ return self.error(f"Expected <{self.val}> to not contain item <{items[0]}>, but did.")
150
145
  else:
151
146
  found = []
152
147
  for i in items:
@@ -154,8 +149,8 @@ class ContainsMixin:
154
149
  found.append(i)
155
150
  if found:
156
151
  return self.error(
157
- "Expected <%s> to not contain items %s, but did contain %s."
158
- % (self.val, self._fmt_items(items), self._fmt_items(found))
152
+ f"Expected <{self.val}> to not contain items {self._fmt_items(items)},"
153
+ f" but did contain {self._fmt_items(found)}."
159
154
  )
160
155
  return self
161
156
 
@@ -191,8 +186,8 @@ class ContainsMixin:
191
186
  extra.append(i)
192
187
  if extra:
193
188
  return self.error(
194
- "Expected <%s> to contain only %s, but did contain %s."
195
- % (self.val, self._fmt_items(items), self._fmt_items(extra))
189
+ f"Expected <{self.val}> to contain only {self._fmt_items(items)},"
190
+ f" but did contain {self._fmt_items(extra)}."
196
191
  )
197
192
 
198
193
  missing = []
@@ -201,8 +196,8 @@ class ContainsMixin:
201
196
  missing.append(i)
202
197
  if missing:
203
198
  return self.error(
204
- "Expected <%s> to contain only %s, but did not contain %s."
205
- % (self.val, self._fmt_items(items), self._fmt_items(missing))
199
+ f"Expected <{self.val}> to contain only {self._fmt_items(items)},"
200
+ f" but did not contain {self._fmt_items(missing)}."
206
201
  )
207
202
  return self
208
203
 
@@ -238,7 +233,7 @@ class ContainsMixin:
238
233
  idx = self.val.find(item, pos)
239
234
  if idx == -1:
240
235
  return self.error(
241
- "Expected <%s> to contain sequence %s, but did not." % (self.val, self._fmt_items(items))
236
+ f"Expected <{self.val}> to contain sequence {self._fmt_items(items)}, but did not."
242
237
  )
243
238
  pos = idx + len(item)
244
239
  return self
@@ -251,7 +246,7 @@ class ContainsMixin:
251
246
  return self
252
247
  except TypeError:
253
248
  raise TypeError("val is not iterable") from None
254
- return self.error("Expected <%s> to contain sequence %s, but did not." % (self.val, self._fmt_items(items)))
249
+ return self.error(f"Expected <{self.val}> to contain sequence {self._fmt_items(items)}, but did not.")
255
250
 
256
251
  def contains_duplicates(self) -> Self:
257
252
  """Asserts that val is iterable and *does* contain duplicates.
@@ -274,7 +269,7 @@ class ContainsMixin:
274
269
  return self
275
270
  except TypeError:
276
271
  raise TypeError("val is not iterable") from None
277
- return self.error("Expected <%s> to contain duplicates, but did not." % self.val)
272
+ return self.error(f"Expected <{self.val}> to contain duplicates, but did not.")
278
273
 
279
274
  def does_not_contain_duplicates(self) -> Self:
280
275
  """Asserts that val is iterable and *does not* contain any duplicates.
@@ -297,7 +292,7 @@ class ContainsMixin:
297
292
  return self
298
293
  except TypeError:
299
294
  raise TypeError("val is not iterable") from None
300
- return self.error("Expected <%s> to not contain duplicates, but did." % self.val)
295
+ return self.error(f"Expected <{self.val}> to not contain duplicates, but did.")
301
296
 
302
297
  def is_empty(self) -> Self:
303
298
  """Asserts that val is empty.
@@ -319,9 +314,9 @@ class ContainsMixin:
319
314
  """
320
315
  if len(self.val) != 0:
321
316
  if isinstance(self.val, str):
322
- return self.error("Expected <%s> to be empty string, but was not." % self.val)
317
+ return self.error(f"Expected <{self.val}> to be empty string, but was not.")
323
318
  else:
324
- return self.error("Expected <%s> to be empty, but was not." % self.val)
319
+ return self.error(f"Expected <{self.val}> to be empty, but was not.")
325
320
  return self
326
321
 
327
322
  def is_not_empty(self) -> Self:
@@ -439,7 +434,7 @@ class ContainsMixin:
439
434
  for i in items:
440
435
  if self.val == i:
441
436
  return self
442
- return self.error("Expected <%s> to be in %s, but was not." % (self.val, self._fmt_items(items)))
437
+ return self.error(f"Expected <{self.val}> to be in {self._fmt_items(items)}, but was not.")
443
438
 
444
439
  def is_not_in(self, *items) -> Self:
445
440
  """Asserts that val is not equal to one of the given items.
@@ -464,5 +459,5 @@ class ContainsMixin:
464
459
  else:
465
460
  for i in items:
466
461
  if self.val == i:
467
- return self.error("Expected <%s> to not be in %s, but was." % (self.val, self._fmt_items(items)))
462
+ return self.error(f"Expected <{self.val}> to not be in {self._fmt_items(items)}, but was.")
468
463
  return self