Metadata-Version: 2.4
Name: assertpy2
Version: 2.3.7
Summary: Fluent assertion library for Python with composable matchers, structural matching, and full type safety
Project-URL: Homepage, https://github.com/Solganis/assertpy2
Project-URL: Repository, https://github.com/Solganis/assertpy2
Project-URL: Issues, https://github.com/Solganis/assertpy2/issues
Author-email: Justin Shacklette <justin@saturnboy.com>
Maintainer-email: Solganis <solganis.dev@gmail.com>
License-Expression: BSD-3-Clause
License-File: LICENSE
Keywords: assert,assert_that,assertion,assertthat,pytest,test,testing,unittest
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: BSD License
Classifier: Natural Language :: English
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3.15
Classifier: Topic :: Software Development
Classifier: Topic :: Software Development :: Testing
Requires-Python: >=3.10
Requires-Dist: typing-extensions>=4.0
Provides-Extra: allure
Requires-Dist: allure-pytest>=2.13; extra == 'allure'
Provides-Extra: behave
Requires-Dist: behave>=1.2.6; extra == 'behave'
Provides-Extra: json
Requires-Dist: jsonpath-ng>=1.8; extra == 'json'
Requires-Dist: jsonschema>=4.0; extra == 'json'
Description-Content-Type: text/markdown

<p align="center">
  <picture>
    <source media="(prefers-color-scheme: dark)" srcset="docs/logo-dark.svg">
    <source media="(prefers-color-scheme: light)" srcset="docs/logo.svg">
    <img src="docs/logo.svg" alt="assertpy2" width="280">
  </picture>
</p>

<p align="center">
  <b>Fluent assertion library for Python with composable matchers, structural matching, and full type safety.</b><br>
  A modern, batteries-included fork of <a href="https://github.com/assertpy/assertpy">assertpy</a>.
</p>

<p align="center">
  <a href="https://github.com/Solganis/assertpy2/actions/workflows/ci.yml"><img src="https://github.com/Solganis/assertpy2/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
  <a href="https://pypi.org/project/assertpy2/"><img src="https://img.shields.io/pypi/v/assertpy2" alt="PyPI version"></a>
  <a href="https://pepy.tech/projects/assertpy2"><img src="https://static.pepy.tech/badge/assertpy2/month" alt="Downloads"></a>
  <a href="https://pypi.org/project/assertpy2/"><img src="https://img.shields.io/pypi/pyversions/assertpy2" alt="Python"></a>
  <a href="https://codecov.io/gh/Solganis/assertpy2"><img src="https://codecov.io/gh/Solganis/assertpy2/graph/badge.svg" alt="Coverage"></a>
  <br>
  <a href="https://github.com/Solganis/assertpy2/blob/main/docs/api.md"><img src="https://img.shields.io/badge/Docs-Read%20The%20Docs-black" alt="Documentation"></a>
  <a href="https://docs.astral.sh/ruff/"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json" alt="Ruff"></a>
  <a href="https://github.com/astral-sh/uv"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json" alt="uv"></a>
  <a href="https://github.com/astral-sh/ty"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json" alt="ty"></a>
  <a href="https://scorecard.dev/viewer/?uri=github.com/Solganis/assertpy2"><img src="https://api.scorecard.dev/projects/github.com/Solganis/assertpy2/badge" alt="OpenSSF Scorecard"></a>
  <a href="https://www.bestpractices.dev/projects/12990"><img src="https://www.bestpractices.dev/projects/12990/badge" alt="OpenSSF Best Practices"></a>
</p>


## Quick start

```bash
pip install assertpy2
```

```py
from assertpy2 import assert_that

def test_user():
    user = {"name": "Alice", "age": 30, "roles": ["viewer", "editor"]}

    assert_that(user).contains_key("name", "age")
    assert_that(user["age"]).is_between(18, 120)
    assert_that(user["roles"]).contains("viewer").does_not_contain("admin")
    assert_that(user).has_name("Alice")
```

Composable matchers and structural matching:

```py
from assertpy2 import assert_that, match

# matchers with & | ~ operators
assert_that([3, 7, 12]).contains(match.greater_than(10))
assert_that(42).satisfies(match.greater_than(0) & match.less_than(100))

# validate dict structure declaratively
assert_that(api_response).matches_structure({
    "id": match.is_uuid(),
    "name": match.equal_to("Alice"),
    "status": match.is_non_empty_string(),
})
```

Structured errors with rich data:

```py
try:
    assert_that({"a": 1, "b": 2}).is_equal_to({"a": 1, "b": 99})
except AssertionError as e:
    e.actual    # {"a": 1, "b": 2}
    e.expected  # {"a": 1, "b": 99}
    e.diff      # DiffResult(kind='dict', entries=[DiffEntry(path='b', actual=2, expected=99)])
```

The pytest plugin auto-renders this as rich diff sections in failure reports:

```
FAILED test_example.py::test_comparison
--- AssertionFailure ---
  actual:   {'a': 1, 'b': 2}
  expected: {'a': 1, 'b': 99}
```


## Comparison

<div align="center">

|  | pytest assert | PyHamcrest | assertpy | **assertpy2** |
|---|:---:|:---:|:---:|:---:|
| **Type safety** | Partial (mypy plugin) | No | No | **py.typed, @overload, Self** |
| **IDE autocomplete** | Generic | Generic | Generic | **Type-specific per value** |
| **Fluent chaining** | No | No | Yes | **Yes** |
| **Composable matchers** | No | Yes (functions) | No | **Yes (`&` `\|` `~` operators)** |
| **Structural matching** | No | Flat (has_entries) | No | **Recursive with matchers** |
| **Async assertions** | No | No | No | **eventually() with polling** |
| **Soft assertions** | No | No | Yes (not thread-safe) | **Yes (thread-safe, async-safe)** |
| **Structured errors** | Rewrite only | Mismatch string | String only | **.actual .expected .diff** |
| **Maintained** | N/A | Minimal | 2020 | **Active** |

</div>


## Why fluent assertions?

```py
# bare assert - passes, but failure message is useless
assert user["age"] >= 18
# AssertionError

# assertpy2 - same check, clear failure message
assert_that(user["age"]).is_greater_than_or_equal_to(18)
# AssertionError: Expected <16> to be greater than or equal to <18>, but was not.

# bare assert - three separate statements
assert isinstance(items, list)
assert len(items) == 3
assert "admin" in items

# assertpy2 - one fluent chain
assert_that(items).is_type_of(list).is_length(3).contains("admin")
```


## Features

- [**Composable matchers**](docs/api.md#composable-matchers): `match.greater_than(5)`, `match.is_uuid()`, combine with `&`, `|`, `~`. Also work with plain `assert ==`.
- [**Structural matching**](docs/api.md#structural-matching): `matches_structure()` for declarative dict/API response validation.
- [**Universal negation**](docs/api.md#universal-negation): `.not_` inverts any assertion without dedicated `is_not_*` methods.
- [**Collection pipeline**](docs/api.md#collection-pipeline): `filtered_on()`, `mapped()`, `flat_mapped()`, `first()`, `last()`, `element()`, `single()` for transforming collections before assertions.
- [**Async assertions**](docs/api.md#async-assertions): `eventually()` with polling/retry for async and eventual consistency testing.
- [**Structured errors**](docs/api.md#structured-errors): `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes, pytest plugin with rich diff output.
- **Typed overloads**: `assert_that()` returns type-specific Protocols, IDE shows only relevant methods per type.
- **Type safety**: `Self` return types, `py.typed` ([PEP 561](https://peps.python.org/pep-0561/)).
- [**Soft assertions**](docs/api.md#soft-assertions): thread-safe and async-safe via `contextvars`, collect all failures with `soft_assertions()`. Group errors with `sa.group()`, or use `assert_all()` for inline checks.
- [**JSON assertions**](docs/api.md#json-path--schema-validation): JSONPath navigation (`at_json_path`, `has_json_path`) and JSON Schema validation (`matches_json_schema`).
- [**Fluent chaining**](docs/api.md#chaining): write assertions as readable one-liners that chain naturally.
- [**Dynamic assertions**](docs/api.md#objects): `has_<name>()` for any attribute, property, or zero-argument method on objects and dicts.
- [**Dict comparison**](docs/api.md#dicts): `is_equal_to()` with `ignore` and `include` for selective key matching.
- [**Extracting**](docs/api.md#objects): flatten collections on attributes with `filter` and `sort` support.
- [**File assertions**](docs/api.md#files): `exists()`, `is_file()`, `is_readable()`, `is_writable()`, `is_executable()` with `pathlib.Path` support.
- [**Snapshot testing**](docs/api.md#snapshot-testing): store and compare data structures in JSON format, inspired by Jest.
- [**Allure integration**](docs/api.md#allure-integration): auto-attach structured diff and actual/expected data to Allure reports.
- [**Behave step matchers**](docs/api.md#behave-step-matchers): ready-made parameter types (`PositiveInt`, `BoolLike`, etc.) for Behave step definitions.
- [**Custom matchers**](docs/api.md#custom-matchers---registering-domain-matchers): register domain-specific matchers via `register_matcher()`, composable with `&`, `|`, `~`.
- [**Regex group extraction**](docs/api.md#regex-group-extraction): `extracting_group()` and `matches_with_groups()` to assert on regex captures fluently.
- [**Extensions**](docs/api.md#extension-system---adding-custom-assertions): add custom assertions via `add_extension()`.
- [Strings](docs/api.md#strings), [numbers](docs/api.md#numbers), [lists](docs/api.md#lists), [tuples](docs/api.md#tuples), [sets](docs/api.md#sets), [dicts](docs/api.md#dicts), [dates](docs/api.md#dates), [booleans](docs/api.md#booleans), [objects](docs/api.md#objects), [exceptions](docs/api.md#failure).


## Composable matchers

Matchers are objects that describe conditions. Combine them with `&` (and), `|` (or), `~` (not):

```py
from assertpy2 import assert_that, match

# check a value against a composed condition
assert_that(42).satisfies(match.greater_than(0) & match.less_than(100))

# matchers inside contains - find element by condition
assert_that([3, 7, 12]).contains(match.greater_than(10))

# check every element in a collection
assert_that([18, 25, 30]).each(match.between(18, 120))

# invert with ~
assert_that("hello").satisfies(~match.equal_to("world"))

# combine freely
assert_that(150).satisfies(match.is_negative() | match.greater_than(100))
```

Matchers also support `==` directly, so you can use them with plain `assert` or mix into dicts and lists:

```py
from assertpy2 import match

assert 42 == match.is_positive()
assert {"id": 5, "name": "Alice"} == {"id": match.is_positive(), "name": match.is_non_empty_string()}
```

Available matchers: `equal_to`, `greater_than`, `greater_than_or_equal_to`, `less_than`, `less_than_or_equal_to`, `between`, `close_to`, `is_none`, `is_not_none`, `is_instance_of`, `has_length`, `is_empty`, `is_not_empty`, `is_positive`, `is_negative`, `is_zero`, `is_even`, `is_odd`, `is_divisible_by`, `is_callable`, `is_in`, `has_property`, `contains_string`, `matches_regex`, `is_uuid`, `is_non_empty_string`, `ignore`, `each_item`, `structure`.


## Structural matching

Validate dict structure declaratively, even when values are dynamic (UUIDs, timestamps):

```py
from assertpy2 import assert_that, match

assert_that(api_response).matches_structure({
    "id": match.is_uuid(),
    "name": match.equal_to("Alice"),
    "created_at": match.is_non_empty_string(),
    "metadata": match.structure({
        "version": match.greater_than(0),
        "tags": match.each_item(match.is_instance_of(str)),
    }),
    "debug_info": match.ignore(),
})
```


## Async assertions

Poll a callable until the assertion passes or timeout is reached:

```py
from assertpy2 import assert_that

async def test_eventual_consistency():
    await assert_that(get_status).eventually().within(5).every(0.5).is_equal_to("ready")

    # works with async callables
    await assert_that(async_get_count).eventually().within(10).is_greater_than(100)
```

Any assertion method is available after `eventually()`. Only `AssertionError` is retried, other exceptions propagate immediately.


## Soft assertions

Collect all failures instead of stopping at the first one:

```py
from assertpy2 import assert_that, soft_assertions

def test_user_profile():
    with soft_assertions():
        assert_that(user.name).is_equal_to("Alice")
        assert_that(user.age).is_greater_than(0)
        assert_that(user.email).contains("@")
```

All failures are reported at the end of the block:

```
AssertionError: soft assertion failures:
1. Expected <Bob> to be equal to <Alice>, but was not.
2. Expected <-1> to be greater than <0>, but was not.
3. Expected <invalid> to contain <@>, but did not.
```

Use `soft_fail("message")` inside the block for non-halting explicit failures (unlike `fail()`, which stops immediately).

Soft assertions are thread-safe and async-safe: each thread and each `asyncio` task gets independent state via `contextvars`.


## Structured errors

When assertions fail, `AssertionFailure` carries structured data alongside the human-readable message:

```py
try:
    assert_that(1).is_equal_to(2)
except AssertionError as e:
    e.actual    # 1
    e.expected  # 2
```

For dict comparisons, a `DiffResult` with per-key diff entries is available:

```py
try:
    assert_that({"a": 1, "b": 2}).is_equal_to({"a": 1, "b": 99})
except AssertionError as e:
    e.diff  # DiffResult(kind='dict', entries=[DiffEntry(path='b', actual=2, expected=99)])
```

`AssertionFailure` is a subclass of `AssertionError`, so all existing `except AssertionError` handlers work unchanged.

The pytest plugin (auto-registered, no configuration needed) renders structured data as extra sections in failure reports:

```
FAILED test_example.py::test_comparison
--- AssertionFailure ---
  actual:   {'a': 1, 'b': 2}
  expected: {'a': 1, 'b': 99}
--- Structured Diff ---
DiffResult(kind='dict', entries=[DiffEntry(path='b', actual=2, expected=99)])
```


## More features

### Universal negation

Invert any assertion with `.not_`:

```py
assert_that(5).not_.is_none()
assert_that("abc123").not_.is_alpha()
assert_that([3, 1, 2]).not_.is_sorted()
assert_that(value).described_as("check").not_.is_none().is_positive()
```

Works with soft assertions and warn mode.

### Collection pipeline

Transform collections before asserting:

```py
orders = [Order("DONE", 100), Order("FAILED", 50), Order("DONE", 200)]

assert_that(orders).filtered_on(lambda o: o.status == "FAILED").is_length(1)
assert_that(orders).mapped(lambda o: o.total).contains(100, 200)
assert_that(orders).first().has_status("DONE")
assert_that(orders).element(1).has_status("FAILED")
assert_that([42]).single().is_equal_to(42)

# chaining pipeline steps
assert_that(items).filtered_on(match.is_positive()).mapped(str).contains("1")
```

Available methods: `filtered_on()`, `mapped()`, `flat_mapped()`, `first()`, `last()`, `element()`, `single()`.

### Grouped soft assertions

```py
with soft_assertions() as sa:
    with sa.group("Headers"):
        assert_that(headers["Content-Type"]).is_equal_to("application/json")
    with sa.group("Body"):
        assert_that(body["status"]).is_equal_to("ok")
        assert_that(body["items"]).is_not_empty()

# or inline with assert_all
assert_all(
    lambda: assert_that(x).is_positive(),
    lambda: assert_that(y).is_not_none(),
)
```

### JSON path and schema validation

Requires `pip install assertpy2[json]`.

```py
data = {"users": [{"name": "Alice"}, {"name": "Bob"}], "meta": {"total": 2}}

assert_that(data).at_json_path("$.users[0].name").is_equal_to("Alice")
assert_that(data).has_json_path("$.meta.total")
assert_that(data).does_not_have_json_path("$.error")
assert_that(data).matches_json_schema({"type": "object", "required": ["users"]})
```

### Dict comparison with ignore/include

```py
assert_that({"a": 1, "b": 2, "c": 3}).is_equal_to({"a": 1}, ignore=["b", "c"])
assert_that({"a": 1, "b": {"c": 2, "d": 3}}).is_equal_to({"b": {"d": 3}}, include=("b", "d"))
```

### Extracting with filter and sort

```py
users = [
    {"user": "Fred", "age": 36, "active": True},
    {"user": "Bob", "age": 40, "active": False},
    {"user": "Johnny", "age": 13, "active": True},
]

assert_that(users).extracting("user", filter="active").is_equal_to(["Fred", "Johnny"])
assert_that(users).extracting("user", sort="age").is_equal_to(["Johnny", "Fred", "Bob"])
```

### Expected exceptions

```py
assert_that(some_func).raises(RuntimeError).when_called_with("bad_arg")\
    .is_length(8).starts_with("some").is_equal_to("some err")
```

### Dynamic assertions

```py
fred = {"first_name": "Fred", "last_name": "Smith", "shoe_size": 12}

assert_that(fred).has_first_name("Fred")
assert_that(fred).has_last_name("Smith")
assert_that(fred).has_shoe_size(12)
```

### Snapshot testing

```py
assert_that({"a": 1, "b": 2, "c": 3}).snapshot()
```

### Custom matchers

Register domain-specific matchers on the `match` namespace with `register_matcher()`:

```py
from assertpy2 import assert_that, match, register_matcher

@register_matcher("is_valid_email")
def is_valid_email():
    return match.matches_regex(r"^[\w.-]+@[\w.-]+\.\w+$")

# parametrised matchers
@register_matcher("has_status")
def has_status(expected: str):
    return match.has_property("status", match.equal_to(expected))

# use everywhere matchers are accepted
assert_that("alice@example.com").satisfies(match.is_valid_email())
assert_that(users).extracting("email").each(match.is_valid_email())
assert_that(data).matches_structure({"email": match.is_valid_email()})

# composition works automatically
assert_that(email).satisfies(match.is_valid_email() & match.contains_string("@company.com"))
```

Remove with `unregister_matcher("is_valid_email")`.

### Regex group extraction

Extract regex groups and continue the fluent chain:

```py
log = "2024-01-15 ERROR status=500 path=/api/users"

# extract a positional group
assert_that(log).extracting_group(r"status=(\d+)", 1).is_equal_to("500")

# extract a named group
assert_that(log).extracting_group(r"(?P<level>\w+) status", "level").is_equal_to("ERROR")

# get all groups as a tuple or dict (named groups)
assert_that("key=value").matches_with_groups(r"(?P<k>\w+)=(?P<v>\w+)") \
    .contains_entry({"k": "key"}).contains_entry({"v": "value"})
```

### Extensions

```py
from assertpy2 import add_extension

def is_5(self):
    if self.val != 5:
        return self.error(f'{self.val} is NOT 5!')
    return self

add_extension(is_5)

assert_that(5).is_5()
```

See the [full API reference](docs/api.md) for all assertion methods, examples, and advanced features.


## Allure integration

When `allure-pytest` is installed, the pytest plugin auto-attaches structured failure data to Allure reports as JSON attachments.

```bash
pip install assertpy2[allure]
```

Three modes controlled via `pytest.ini` (or `pyproject.toml`):

| Mode | What is attached |
|---|---|
| `diff` (default) | Structured Diff JSON (path-level breakdown) |
| `full` | Structured Diff + actual/expected JSON |
| `off` | Nothing |

```toml
# pyproject.toml
[tool.pytest.ini_options]
assertpy2_allure = "full"
```


## Behave step matchers

Ready-made parameter types for Behave step definitions:

```bash
pip install assertpy2[behave]
```

```py
# in environment.py or steps/conftest.py
from assertpy2.behave_matchers import register_assertpy_types
register_assertpy_types()
```

Then use in step definitions:

```py
@given('a user aged {age:PositiveInt}')
def step_impl(context, age):
    context.age = age  # already validated as int > 0
```

Available types: `PositiveInt`, `NonNegativeInt`, `PositiveFloat`, `NonEmptyString`, `BoolLike`.


## Migration from assertpy

assertpy2 is a drop-in replacement for Python 3.10+. Change the import, everything else works:

```py
# before
from assertpy import assert_that, soft_assertions

# after
from assertpy2 import assert_that, soft_assertions
```

See the [comparison table](#comparison) above for feature differences with other libraries.


## Contributing

Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for details.


