Skip to content

2.30.0

Choose a tag to compare

@Solganis Solganis released this 06 Oct 21:33
45c67b0

Read Behaviour changes below before upgrading.

TL;DR

New
conforms_to_openapi with status= a status is found under its range (200 under 2XX) or under default, as OpenAPI 3 declares responses
conforms_to_openapi with strict_nullable=True holds OpenAPI 3.0's nullable to its text: with no type beside it the keyword does nothing
.diff on an assert_conforms(..., exact=True) failure one entry per undeclared field, with the value sent and the steps that lead to it
Step kind json a path that enters JSON text holds what the text decodes to
pytest -vv a message and the report section print a value past the cap whole
a failed matches_structure whose spec holds a class where the value is an instance of it a line naming the place and match.is_instance_of
match.each_item, has_property, not_, all_of, any_of, & or ` ` given a class
A failed is_equal_to or has_<name>() under the pytest plugin is handed to pytest_assertrepr_compare listeners, which is how PyCharm opens its comparison window. Under ignore= or include= the two values go without the keys left out
.actual, .expected and .diff on a failed has_<name>() the value read, the operand and their diff, as is_equal_to on that value carries them
Passed when it should not have
assert_conforms(..., exact=True) with an undeclared field in a dataclass, or in a TypedDict itself passed
assert_conforms(..., exact=True) with an undeclared field a validator removed from the payload in place passed
assert_conforms(..., exact=True) with an undeclared field inside a typed extra, where an after validator cleared __pydantic_extra__ passed
is_equal_to with ignore= or include= on a dict that holds itself and differs below the key left out passed
match.equal_to with strict_types=True and ignore= or include= on a sequence whose elements differ in type matched
is_equal_to with ignore= or include= on two values that hold something outside their __dict__, as an element or under a path: a list or array of a class of your own, an exception, a class with a slot passed
is_equal_to with strict_types=True and ignore= or include= on a record holding a dict of a class of your own passed
is_equal_to with ignore= or include= on two records holding an OrderedDict each, the same items in another order passed
is_close_to, match.close_to and is_equal_to(..., tolerance=) on a numpy integer whose distance wraps at its width: numpy.uint8(3) against 250 within 10 passed
is_less_than and the other relations, and is_sorted, on a Fraction against a numpy integer passed or failed by a product that wrapped
is_close_to, match.close_to and is_equal_to(..., tolerance=) on two integers past 2**53 under a float tolerance, 2**53 + 1 against 2**53 within 0.0 passed
a second assertion in a chain over a generator, an iterator, a zip or a map, asking for an absence: contains(1).does_not_contain(1) passed over nothing, the first link having read the value
conforms_to_openapi on a text that is no moment under format: date-time, or a number past the range under format: int32 or int64 passed
conforms_to_openapi on a text that is no URI under format: uri, no host name under format: hostname or no duration under format: duration passed
conforms_to_openapi on a time with no offset under format: time, such as 10:00:00 passed
conforms_to_openapi on a text that is no mailbox under format: email, such as @ or ann lee@shop.example passed
conforms_to_openapi under OpenAPI 3.1 on a host name with an xn-- label that is no valid A-label, such as xn--X passed
conforms_to_openapi under OpenAPI 3.0 on a null against nullable: true on an allOf, anyOf or oneOf with an enum beside it that leaves null out passed
conforms_to_openapi under OpenAPI 3.0 or Swagger 2.0 on a response schema that holds const, contains, if or another keyword of a later JSON Schema passed whatever the keyword forbids
conforms_to_openapi under OpenAPI 3.0 on a null where nullable: true stands beside an enum that leaves null out passed
is_equal_to and match.equal_to with ignore= or include= on a list against a tuple passed
is_equal_to and match.equal_to with strict_types=True, or a comparator stricter than ==, on an object compared by its own == or on a collections.deque passed whatever type its attributes or items held
is_equal_to and match.equal_to with strict_types=True on a tuple or a frozenset used as a dict key or a set member passed for {(1, 2): "a"} against {(True, 2): "a"}
is_equal_to_ignoring_seconds and is_equal_to_ignoring_milliseconds on the two readings of a zone's repeated hour passed, an hour apart
match.starts_with(""), match.ends_with(""), match.matches_regex("") matched every text
is_child_of given the file itself passed
an awaited eventually() chain holding described_as and no assertion passed
Failed when it should have passed
is_equal_to with a path such as ignore=("owner", "seen_at") on a dict beside a dataclass holding the same failed
is_equal_to with ignore= or include= beside tolerance= or comparators= on a sequence holding a list failed on the nested list
is_equal_to with ignore= or include= on a dataclass, attrs instance or model beside a dict, each holding an equal record failed
all_fields_satisfy on a value that holds itself asked the predicate about '<circular ref>'
is_equal_to on two values whose class holds a descriptor under __eq__ that acts when read on the class failed
is_close_to with a numpy integer as the tolerance, is_not_close_to on a numpy integer far from the other number, a relation on a Fraction against a small numpy integer failed
is_greater_than on numpy.int64(2**53 + 1) against numpy.float64(2**53) failed
a second assertion in a chain over a generator, an iterator, a zip or a map, asking for a presence: contains(1).contains(3) failed over nothing
is_equal_to with strict_types=True and a path that leaves out a key one nested dict lacks failed
conforms_to_openapi under OpenAPI 3.0 on a response without a property that is required and writeOnly failed
conforms_to_openapi on a time as RFC 3339 writes it under format: time, such as 10:00:00Z failed
conforms_to_openapi on a decimal multiple of a multipleOf, such as 19.99 under multipleOf: 0.01 failed
conforms_to_openapi on a value an enum lists, where that member looks like a schema holding nullable failed
is_equal_to with tolerance= on two collections.deque of floats within the tolerance failed
contains_sequence and contains_in_order for the very NaN the value holds failed
Crashed or misreported
matches_structure failing on a text beside a class in the spec printed the text as a number, but was <7>, with no type
closeness or a relation on a numpy integer under -W error, which is how a suite with filterwarnings = error runs RuntimeWarning: overflow encountered in scalar subtract in place of a verdict
is_between with a Fraction and a numpy integer as its bounds ValueError: given low arg must be less than given high arg for bounds in order
is_equal_to with ignore=() or include=() IndexError: tuple index out of range
conforms_to_openapi with status=HTTPStatus.NOT_FOUND on Python 3.10 ValueError: ... declares no response <HTTPStatus.NOT_FOUND>
conforms_to_openapi on a number held to multipleOf: 0 ZeroDivisionError: integer division or modulo by zero in place of a refusal that names the keyword
match.is_now with an infinite delta, or one past what a timedelta holds OverflowError out of timedelta
a refusal under pytest, of match.each_item and the other matcher factories, of a matcher operator, of an argument showed up to four frames of the library under the line that wrote it
val and value on check() or not_ where the subject is callable a wrapper in place of the subject, which called the subject when it was called
is_equal_to with ignore=, include=, strict_types=, tolerance= or comparators= on a dataclass, attrs instance or model that holds itself RecursionError
is_equal_to failing on two values compared by identity, or on a list against a tuple a diff with no entry
the diff of a value that holds itself a row '<circular ref>' naming no difference
is_equal_to with ignore= or include= failing on a list or tuple named the first element only, without its index, or carried no diff
is_equal_to with ignore= or include= on a record holding a dict keyed by a frozen dataclass TypeError: unhashable type
is_equal_to failing on two values that print the same a row with one text on both sides and no word why
is_equal_to with ignore= or include= failing on a dataclass, attrs instance, named tuple or model printed it as a dict, without its class
any assertion but is_equal_to failing on a value of thousands of characters printed all of it ahead of what was asked
matches_structure failing on a record of many fields printed the whole record and the whole spec ahead of the one field it is about
is_equal_to failing on many rows that differ in one field a diff with a row for each and no word that they are one field
contains failing on a list of dataclasses, or for a NaN, an id spelled as text or an instance compared by identity named no closest row and no reason
is_in, contains_only, is_subset_of, contains_value and the rest of the family failing for the same printed <7> on both sides, did contain <7> and did not contain <7>, and no closest row
has_<name>() failing on an id held as text Expected <7> to be equal to <7> on attribute <id>
has_<name>() failing on two dicts or two long lists printed both whole
the report section of is_equal_to failing under ignore= or include= rows that part at the key left out
the diff of a failed contains for a dict missing: {{'id': 99}}
is_equal_to failing on a tuple of one long element printed it as ('...'), without its comma
is_equal_to on two lists of different lengths that hold themselves RecursionError
is_equal_to failing on two sets that hold the same members, one of a class whose == says otherwise a diff with no entry, which rendered as nothing
matches_structure failing on a leaf that prints the same expected <7>, but was <7>
contains_sequence on a value shorter than the sequence, or holding its start too late to fit No run started with <1> with 1 in it
the closest element of contains where one side lacks a key name (None != None)
is_equal_to with ignore= or include= on an exception at the top, where 2.29.1 read its attributes alone ValueError("a") equal to ValueError("b")
Typing: refused now, and accepted by ty before
A wrong operand after is_not_none() on an optional number, sequence, set, datetime or callable ty read the chain after it as Unknown
A text assertion on eventually() over an async def probe that returns a number ty read the chain as Unknown
A misuse of what returned() holds after warns() ty read it as Unknown
A text, key or numeric assertion on check() after a pivot, asked of a value of another kind ty read no restriction on that view
Typing: refused now, and accepted by mypy before
A text assertion on a value that is a number or a text, on check() after a pivot and on a polling chain mypy took the value for one a capability claims
Typing: typed now, and a verdict call before
val and value on check() after a pivot the value, under all four checkers
For code that reads a failure
failure.actual of a failed has_<name>() is the value read, where it was the whole subject. failure.expected is the operand
failure.actual, failure.expected and the paths of a failed is_equal_to on a list or tuple under ignore= or include= are the two sequences and paths from them, in one failure, where the failure was about one item or the length
failure.diff of a failed is_equal_to always holds an entry, where it could be None or empty
the entries of a diff over a value that holds itself hold no '<circular ref>'
Step.kind can be "json", to be handled where every kind is
a listener of pytest_assertrepr_compare is called for a failed is_equal_to and has_<name>() with the pair compared

An exact=True failure carries a structured diff

class Customer(BaseModel):
    id: int

class Order(BaseModel):
    id: int
    customer: Customer

payload = {"id": 1, "customer": {"id": 7, "loyalty_tier": "gold"}, "promo_code": "SPRING"}
try:
    assert_conforms(payload, Order, exact=True)
except AssertionFailure as failure:
    for entry in failure.diff.entries:
        print(entry.path, repr(entry.actual), [(step.kind, step.value) for step in entry.steps])

Before

AttributeError: 'NoneType' object has no attribute 'entries'

Now

customer.loyalty_tier 'gold' [('key', 'customer'), ('key', 'loyalty_tier')]
promo_code 'SPRING' [('key', 'promo_code')]

An undeclared field is an entry with absent == "expected", a part that cannot be checked is one entry with the reason in expected.

Guide: Contract drift with exact=True, Paths a program can follow

Steps that enter JSON text

A json step stands at JSON text, and its value is the decoded object the later steps lead into.

class Event(BaseModel):
    body: Json[Customer]

payload = {"body": '{"id": 7, "loyalty_tier": "gold"}'}
try:
    assert_conforms(payload, Event, exact=True)
except AssertionFailure as failure:
    (entry,) = failure.diff.entries
    value = payload
    for step in entry.steps:
        value = step.value if step.kind in ("item", "json") else value[step.value]
    print(entry.path, [step.kind for step in entry.steps], value is entry.actual)

Before

AttributeError: 'NoneType' object has no attribute 'entries'

Now

body.loyalty_tier ['key', 'json', 'key'] True

Guide: Paths a program can follow

exact=True reads the payload as it was sent

class Account(BaseModel):
    id: int

    @model_validator(mode="before")
    @classmethod
    def drop_internal(cls, value):
        value.pop("internal", None)
        return value

assert_conforms({"id": 1, "internal": True}, Account, exact=True)

Before

passed

Now

AssertionFailure: Expected <{'id': 1, 'internal': True}> to conform exactly to <Account>, but it carries 1 undeclared field(s) the model does not declare: ['internal']
diff (match):
  internal: - True

The payload is back to what validation left once the check returns.
A model with a validator of its own costs about a sixth more on a small payload.

Guide: Contract drift with exact=True

Clearing typed extras after validation no longer hides undeclared fields

An after validator that clears, replaces or pops typed extras took them out of the check, with the fields their type had dropped.

class Tag(BaseModel):
    name: str

class Labelled(BaseModel):
    model_config = ConfigDict(extra="allow")
    __pydantic_extra__: dict[str, Tag]
    id: int

    @model_validator(mode="after")
    def keep_no_extras(self):
        self.__pydantic_extra__.clear()
        return self

assert_conforms({"id": 1, "primary": {"name": "a", "colour": "red"}}, Labelled, exact=True)

Before

passed

Now

AssertionFailure: Expected <{'id': 1, 'primary': {'name': 'a', 'colour': 'red'}}> to conform exactly to <Labelled>, but it carries 1 undeclared field(s) the model does not declare: ['primary.colour']
diff (match):
  primary.colour: - 'red'

The validators of Tag run once more for a removed extra, those of Labelled do not.
Untyped extras are not checked.

Guide: Contract drift with exact=True

exact=True checks dataclasses and TypedDicts

@dataclasses.dataclass
class Address:
    city: str

class Point(TypedDict):
    x: int

class Shipment(BaseModel):
    address: Address
    origin: Point

assert_conforms({"address": {"city": "Riga", "zip": "1010"}, "origin": {"x": 1}}, Shipment, exact=True)

Before

passed

Now

AssertionFailure: Expected <{'address': {'city': 'Riga', 'zip': '1010'}, 'origin': {'x': 1}}> to conform exactly to <Shipment>, but it carries 1 undeclared field(s) the model does not declare: ['address.zip']
diff (match):
  address.zip: - '1010'

A TypedDict is checked in a union, under a text annotation and in the place of a type variable too.

class Circle(TypedDict):
    kind: Literal["circle"]
    radius: int

class Square(TypedDict):
    kind: Literal["square"]
    side: int

class Drawing(BaseModel):
    shape: Circle | Square

assert_conforms({"shape": {"kind": "circle", "radius": 1, "colour": "red"}}, Drawing, exact=True)

Before

passed

Now

AssertionFailure: Expected <{'shape': {'kind': 'circle', 'radius': 1, 'colour': 'red'}}> to conform exactly to <Drawing>, but it carries 1 undeclared field(s) the model does not declare: ['shape.colour']
diff (match):
  shape.colour: - 'red'

Guide: Contract drift with exact=True

A value that holds itself under ignore=, include= and a compare option

@dataclasses.dataclass
class Account:
    id: int
    updated_at: int
    owner: "Account | None" = None

def account(id, updated_at):
    made = Account(id, updated_at)
    made.owner = made
    return made

assert_that(account(1, 5)).is_equal_to(account(1, 9), include="id")
assert_that(account(1, 5)).is_equal_to(account(1, 5), strict_types=True)

Before

RecursionError: maximum recursion depth exceeded

Now

passed

A cycle through a list or a tuple, and a record holding a dict keyed by a frozen dataclass, are compared too.
Still raises: such a pair without one of those arguments, which is Python's own ==.

@dataclasses.dataclass(frozen=True)
class Sku:
    code: str

@dataclasses.dataclass
class Stock:
    counted_at: int
    levels: dict

assert_that(Stock(1, {Sku("a"): 3})).is_equal_to(Stock(2, {Sku("a"): 3}), ignore="counted_at")

Before

TypeError: unhashable type: 'TakenApart'

Now

passed

Guide: Selective comparison

A key option applies at its own level of a value that holds itself

def node(id):
    made = {"id": id}
    made["self"] = made
    return made

assert_that(node(1)).is_equal_to(node(2), ignore="id")

Before

passed

Now

AssertionFailure: Expected <{'self': {'id': 1, 'self': {<circular ref>}}}> to be equal to <{'self': {'id': 2, 'self': {<circular ref>}}}> ignoring keys <id>, but was not.
diff (dict):
  self.id:
    - 1
    + 2

ignore="id" leaves out the id of the value compared, not the id of what it leads back to, as on {"id": 1, "self": {"id": 1}}.

Guide: Selective comparison

A sequence under ignore= or include= fails once, with every element that differs

rows = [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}, {"id": 3, "name": "c"}]
other = [{"id": 7, "name": "a"}, {"id": 8, "name": "X"}, {"id": 9, "name": "Y"}]

assert_that(rows).is_equal_to(other, ignore="id")

Before

AssertionFailure: Expected <{'name': 'b'}> to be equal to <{'name': 'X'}> ignoring keys <id>, but was not.
diff (dict):
  name:
    - 'b'
    + 'X'

Now

AssertionFailure: Expected <[.., {'name': 'b'}, {'name': 'c'}]> to be equal to <[.., {'name': 'X'}, {'name': 'Y'}]> ignoring keys <id>, but was not.
diff (sequence):
  [1].name:
    - 'b'
    + 'X'
  [2].name:
    - 'c'
    + 'Y'

A row put in at the head is one entry, as it is without a key option.

assert_that(rows).is_equal_to([{"id": 0, "name": "new"}, *rows], ignore="id")

Before

AssertionFailure: Expected collection length <4>, but was <3>.

Now

AssertionFailure: Expected <[{'name': 'a'}, {'name': 'b'}, {'name': 'c'}]> to be equal to <[{'name': 'new'}, ..]> ignoring keys <id>, but was not.
diff (sequence):
  expected[0]: + {'name': 'new'}

Guide: Selective comparison

A value that holds more than its attributes is not compared by them

class Batch(list):
    pass

assert_that([Batch([1])]).is_equal_to([Batch([2])], ignore="id")

Before

passed

Now

AssertionFailure: Expected <[[1]]> to be equal to <[[2]]> ignoring keys <id>, but was not.
diff (sequence):
  [0][0]:
    - 1
    + 2

A subclass of a builtin container and a class with a slot were read as their attributes alone, and are compared by their own == now.

Guide: Selective comparison

An exception under ignore= or include= is read as its class, args and attributes

class Failed(Exception):
    def __init__(self, message, code):
        super().__init__(message)
        self.code = code

assert_that(Failed("no such user", 404)).is_equal_to(Failed("gone", 404), ignore="code")

Before

passed

Now

AssertionFailure: Expected <Failed(.., args=('no such user',))> to be equal to <Failed(.., args=('gone',))> ignoring keys <code>, but was not.
diff (dict):
  args[0]:
    - 'no such user'
    + 'gone'

The class is a field too, __class__: ValueError("x") and TypeError("x") differ unless it is left out by name.
An OSError, an exception group and a class with a slot keep state in neither, and are compared by their own ==.

Guide: Selective comparison

strict_types=True under a key option sees the class of a dict

class Headers(dict):
    pass

@dataclasses.dataclass
class Reply:
    id: int
    headers: dict

assert_that(Reply(1, Headers(etag="a"))).is_equal_to(Reply(2, {"etag": "a"}), ignore="id", strict_types=True)

Before

passed

Now

AssertionFailure: Expected <Reply(headers=Headers({'etag': 'a'}))> to be equal to <Reply(headers={'etag': 'a'})> ignoring keys <id>, but was not.
compared with strict_types=True
the values on both sides are equal, and only their types differ
diff (dict):
  headers:
    - Headers({'etag': 'a'})
    + {'etag': 'a'}

Without ignore= the same pair already failed.

Guide: Recursive comparison

A path enters a dict as it enters a record

@dataclasses.dataclass
class Owner:
    name: str
    seen_at: int = 0

assert_that({"owner": {"name": "ann", "seen_at": 5}}).is_equal_to({"owner": Owner("ann", 9)}, ignore=("owner", "seen_at"))

Before

AssertionFailure: Expected <{..}> to be equal to <{..}> ignoring keys <owner.seen_at>, but was not.

Now

passed

The same pair already passed at the top (ignore="seen_at") and as an element of a list.
Under strict_types=True it still fails, at owner.

Guide: Selective comparison

A dict beside a record is read as deep as the record

@dataclasses.dataclass
class Customer:
    name: str

@dataclasses.dataclass
class Order:
    id: int
    customer: Customer

assert_that(Order(1, Customer("Ann"))).is_equal_to({"id": 2, "customer": Customer("Ann")}, ignore="id")

Before

AssertionFailure: Expected <{'customer': {'name': 'Ann'}}> to be equal to <{'customer': Customer(name='Ann')}> ignoring keys <id>, but was not.
the contents match field for field, and only the type of the two sides differs
diff (dict):
  customer:
    - {'name': 'Ann'}
    + Customer(name='Ann')

Now

passed

The same holds for an element of a sequence, under a path that enters both, and for match.equal_to.

Guide: Selective comparison

A failed comparison's diff names what differs

@dataclasses.dataclass(eq=False)
class Token:
    value: str

assert_that([Token("a")]).is_equal_to([Token("a")])

Before

AssertionFailure: Expected <[Token(value='a')]> to be equal to <[Token(value='a')]>, but was not.

Now

AssertionFailure: Expected <[Token(value='a')]> to be equal to <[Token(value='a')]>, but was not.
a difference here prints the same on both sides, and the class of the two leaves __eq__ to object, so two separate instances are never equal
diff (sequence):
  [0]:
    - Token(value='a')
    + Token(value='a')

Where == holds two values apart and nothing inside them differs, the entry is the pair itself.

def looped(last):
    made = [1, last]
    made.append(made)
    return made

assert_that(looped(2)).is_equal_to(looped(9))

Before

AssertionFailure: Expected <[1, 2, [...]]> to be equal to <[1, 9, [...]]>, but was not.
diff (sequence):
  [1]:
    - 2
    + 9
  [2]:
      '<circular ref>'

Now

AssertionFailure: Expected <[1, 2, [...]]> to be equal to <[1, 9, [...]]>, but was not.
diff (sequence):
  [1]:
    - 2
    + 9

Guide: Rich pytest diffs

Two values that print the same are told apart

Two sides of two classes are each printed with its class, and with its module where the classes share a name.

class Headers(dict):
    pass

assert_that({"headers": Headers(etag="a")}).is_equal_to({"headers": {"etag": "a"}}, strict_types=True)

Before

AssertionFailure: Expected <{'headers': {'etag': 'a'}}> to be equal to <{'headers': {'etag': 'a'}}>, but was not.
compared with strict_types=True
the values on both sides are equal, and only their types differ
diff (dict):
  headers:
    - {'etag': 'a'}
    + {'etag': 'a'}

Now

AssertionFailure: Expected <{'headers': {'etag': 'a'}}> to be equal to <{'headers': {'etag': 'a'}}>, but was not.
compared with strict_types=True
the values on both sides are equal, and only their types differ
diff (dict):
  headers:
    - {'etag': 'a'}:Headers
    + {'etag': 'a'}:dict

Two sides of one class get a line that says what is known, with the attributes of the two that differ.

class Money:
    def __init__(self, amount, currency):
        self.amount, self.currency = amount, currency

    def __eq__(self, other):
        return (self.amount, self.currency) == (other.amount, other.currency)

    def __repr__(self):
        return f"Money({self.amount})"

assert_that({"total": Money(5, "EUR")}).is_equal_to({"total": Money(5, "USD")})

Before

AssertionFailure: Expected <{'total': Money(5)}> to be equal to <{'total': Money(5)}>, but was not.
diff (dict):
  total:
    - Money(5)
    + Money(5)

Now

AssertionFailure: Expected <{'total': Money(5)}> to be equal to <{'total': Money(5)}>, but was not.
a difference here prints the same on both sides, so what holds the two apart is not in their repr (attributes that differ: currency)
diff (dict):
  total:
    - Money(5)
    + Money(5)

A record compared under ignore= or include= is printed as the record it is.

@dataclasses.dataclass
class Row:
    id: int
    name: str

assert_that(Row(1, "a")).is_equal_to(Row(2, "b"), ignore="id")

Before

AssertionFailure: Expected <{'name': 'a'}> to be equal to <{'name': 'b'}> ignoring keys <id>, but was not.

Now

AssertionFailure: Expected <Row(name='a')> to be equal to <Row(name='b')> ignoring keys <id>, but was not.

Guide: Some failures say why, not only what

A long value is capped in every message

rows = [{"id": n, "name": f"user{n}", "tags": ["a", "b"]} for n in range(300)]

assert_that(rows).is_length(299)

Before

AssertionFailure: Expected <[{'id': 0, 'name': 'user0', 'tags': ['a', 'b']}, […], {'id': 299, 'name': 'user299', 'tags': ['a', 'b']}]> to be of length <299>, but was <300>.

Now

AssertionFailure: Expected <[{'id': 0, 'name': 'user0', 'tags': ['a', 'b']}, […]... (11380 more chars) ...[…], {'id': 299, 'name': 'user299', 'tags': ['a', 'b']}]> to be of length <299>, but was <300>.

The cap is 4000 characters, as is_equal_to already had, for a value, an operand, a pattern and a list of items.

Guide: What a failure exposes

A failure says where its differences sit

rows = [{"id": n, "name": f"user{n}", "updated_at": f"10:00:{n:02d}"} for n in range(51)]
other = [dict(row, updated_at=f"11:00:{n:02d}") for n, row in enumerate(rows)]
other[50]["name"] = "renamed"

assert_that(rows).is_equal_to(other)

Before

AssertionFailure: Expected <[…]> to be equal to <[…]>, but was not.
diff (sequence):
  [0].updated_at:
    - '10:00:00'
    ?   ^
    + '11:00:00'
    ?   ^
  […]
  ... and 2 more entries

Now

AssertionFailure: Expected <[…]> to be equal to <[…]>, but was not.
the 52 differences here are at <[*].updated_at> (51) and <[*].name> (1)
diff (sequence):
  […]
  ... and 2 more entries

The line counts every entry, the name past the fifty rows a diff prints included.

Guide: Some failures say why, not only what

A failed contains, and each of its siblings, names the closest row and the reason

@dataclasses.dataclass
class User:
    id: int
    name: str

assert_that([User(1, "ann"), User(2, "bob")]).contains(User(2, "rob"))

Before

AssertionFailure: Expected <[User(id=1, name='ann'), User(id=2, name='bob')]> to contain item <User(id=2, name='rob')>, but did not.

Now

AssertionFailure: Expected <[User(id=1, name='ann'), User(id=2, name='bob')]> to contain item <User(id=2, name='rob')>, but did not. Closest element <User(id=2, name='bob')> differs at .name ('bob' != 'rob').
assert_that([7, 8]).contains("7")

Before

AssertionFailure: Expected <[7, 8]> to contain item <7>, but did not.

Now

AssertionFailure: Expected <[7, 8]> to contain item <7>, but did not.
an element reads the same as the item not found and is of another type: int, not str

The line is also said of a NaN, and of an instance that prints like an element and compares by identity.

Every assertion that looks for something says it in its own words, for the one item it did not find.

assert_that("7").is_in(7, 8)
assert_that([7, 8]).contains_only("7", 8)
assert_that(User(2, "rob")).is_in(User(1, "ann"), User(2, "bob"))
assert_that({"status": "inactive", "plan": "free"}).contains_entry(status="active")

Before

AssertionFailure: Expected <7> to be in <7, 8>, but was not.
AssertionFailure: Expected <[7, 8]> to contain only <'7', 8>, but did contain <7> and did not contain <7>.
AssertionFailure: Expected <User(id=2, name='rob')> to be in <User(id=1, name='ann'), User(id=2, name='bob')>, but was not.
AssertionFailure: Expected <{'status': 'inactive', 'plan': 'free'}> to contain entries <{'status': 'active'}>, but did not contain <{'status': 'active'}>.

Now

AssertionFailure: Expected <7> to be in <7, 8>, but was not.
a given item reads the same as the value and is of another type: int, not str

AssertionFailure: Expected <[7, 8]> to contain only <'7', 8>, but did contain <7:int> and did not contain <7:str>.
an element reads the same as the item not found and is of another type: int, not str

AssertionFailure: Expected <User(id=2, name='rob')> to be in <User(id=1, name='ann'), User(id=2, name='bob')>, but was not. Closest item <User(id=2, name='bob')> differs at .name ('rob' != 'bob').

AssertionFailure: Expected <{'status': 'inactive', 'plan': 'free'}> to contain entries <{'status': 'active'}>, but did not contain <{'status': 'active'}>. Key <status> holds <'inactive'>.

An assertion that counts says the two counts of the one item it holds too few times, or too many.

assert_that([7, "7"]).contains_exactly(7, "7", "7")
assert_that(["a", "b", "b"]).contains_exactly_in_any_order("a", "b")

Before

AssertionFailure: Expected <[7, '7']> to contain exactly <7, '7', '7'>, but did not.
AssertionFailure: Expected <['a', 'b', 'b']> to contain exactly <'a', 'b'> in any order, but did not.

Now

AssertionFailure: Expected <[7, '7']> to contain exactly <7, '7', '7'>, but did not. <'7'> is held 1 time and was asked for 2 times.
AssertionFailure: Expected <['a', 'b', 'b']> to contain exactly <'a', 'b'> in any order, but did not. <'b'> is held 2 times and was asked for 1 time.

The counts are said where everything missing is one item the value holds, or, with nothing missing, everything left over is one item that was asked for.

A key that is not there is named by the one that reads almost the same.

response = {"user_id": 2, "email": "a@b"}
assert_that(response).contains_key("userId")
assert_that(response).has_emial("a@b")

Before

AssertionFailure: Expected <{'user_id': 2, 'email': 'a@b'}> to contain key <userId>, but did not.
AssertionFailure: Expected key <emial>, but val has no key <emial>.

Now

AssertionFailure: Expected <{'user_id': 2, 'email': 'a@b'}> to contain key <userId>, but did not.
a key reads the same but for how its words are joined: <user_id>

AssertionFailure: Expected key <emial>, but val has no key <emial>.
a key is spelled almost the same: <email>

A key that differs only in case is named too. One spelled almost the same is named where it is the only one that close. Said by contains_key, contains on a mapping, contains_entry and has_<name>(), the last of an attribute as well.

Covered: contains, contains_key, contains_only, contains_exactly, contains_exactly_in_any_order, contains_only_once, contains_sequence, contains_in_order, is_in, is_subset_of, contains_value, contains_entry. A negation is left as it was: it fails because what it was handed is there.

Guide: Some failures say why, not only what

A text held against a text says what keeps the relation from holding

assert_that(process.stdout).ends_with("done")                 # the output ends with a line break
assert_that(page.inner_text()).contains("Total: 42")          # the page has a no-break space after the colon
assert_that(response.headers["content-type"]).starts_with("Application/json")

Before

AssertionFailure: Expected <done
> to end with <done>, but did not.

AssertionFailure: Expected <Total: 42 EUR> to contain item <Total: 42>, but did not.

AssertionFailure: Expected <application/json; charset=utf-8> to start with <Application/json>, but did not.

Now

AssertionFailure: Expected <done
> to end with <done>, but did not.
the value ends with a line break, and ends with the suffix before it

AssertionFailure: Expected <Total: 42 EUR> to contain item <Total: 42>, but did not.
the value holds it once every run of whitespace is read as one space

AssertionFailure: Expected <application/json; charset=utf-8> to start with <Application/json>, but did not.
the value starts with the prefix once case is ignored

Said by starts_with, ends_with, contains on a text, their _ignoring_case twins, is_equal_to_ignoring_case and is_equal_to_ignoring_whitespace. For two texts is_equal_to now also names a difference in the kind or amount of whitespace, past line endings and surrounding whitespace. One fact is said. Apart from is_equal_to, which reads bytes and a str of any class as before, the lines are said of plain str alone.

Guide: Some failures say why, not only what

Two moments say how far apart they are

assert_that(order.paid_at).is_before(order.shipped_at)            # two zones
assert_that(row.updated_at).is_after(row.created_at)              # the same moment
assert_that(event.at).is_close_to(expected, timedelta(milliseconds=500))

Before

AssertionFailure: Expected <2026-01-01 12:00:00+00:00> to be before <2026-01-01 13:30:00+02:00>, but was not.

AssertionFailure: Expected <2026-01-01 12:00:00.123456> to be after <2026-01-01 12:00:00.123456>, but was not.

AssertionFailure: Expected <2026-01-01 12:00:00> to be close to <2026-01-01 12:00:00> within tolerance <0:00:00>, but was not.

Now

AssertionFailure: Expected <2026-01-01 12:00:00+00:00> to be before <2026-01-01 13:30:00+02:00>, but was not.
the value is 0:30:00 after the moment given

AssertionFailure: Expected <2026-01-01 12:00:00.123456> to be after <2026-01-01 12:00:00.123456>, but was not.
the two are the same moment

AssertionFailure: Expected <2026-01-01 12:00:00> to be close to <2026-01-01 12:00:00.900000> within tolerance <0:00:00.500000>, but was not.
the two are 0:00:00.900000 apart, 0:00:00.400000 more than the tolerance

Said by is_before, is_after, is_before_or_equal_to, is_after_or_equal_to, and for two datetimes by is_less_than, is_greater_than, their _or_equal_to forms, is_close_to and is_not_close_to. The distance is exact to the microsecond. A datetime subclass that compares its own way gets no line. Two that share a zone across a change of its clocks, or share a zone that is not the standard library's, get a line about the clock they share (the value reads 4:00:00 before the moment given on the clock the two share). Two in different zones get a line where both zones are datetime.timezone or zoneinfo.ZoneInfo.

Guide: Some failures say why, not only what

A datetime in a failed closeness or number relation prints whole

assert_that(sent_at).is_less_than(received_at)                 # 12:00 UTC against 13:30 two hours east
assert_that(first).is_greater_than(second)                     # 3 ms apart

Before

AssertionFailure: Expected <2026-01-01 12:00:00> to be less than <2026-01-01 13:30:00>, but was not.

AssertionFailure: Expected <2026-01-01 12:00:00> to be greater than <2026-01-01 12:00:00>, but was not.

Now

AssertionFailure: Expected <2026-01-01 12:00:00+00:00> to be less than <2026-01-01 13:30:00+02:00>, but was not.
the value is 0:30:00 after the moment given

AssertionFailure: Expected <2026-01-01 12:00:00.120001> to be greater than <2026-01-01 12:00:00.123456>, but was not.
the value is 0:00:00.003455 before the moment given

is_close_to, is_not_close_to, is_less_than, is_greater_than, their _or_equal_to forms, is_between and is_not_between printed a datetime to the second and without its offset, and a timedelta tolerance cut to whole seconds. They print it as is_before does, and the tolerance with its fraction. A message that dropped nothing reads as it did.

A negative timedelta tolerance is refused

assert_that(event.at).is_not_close_to(deadline, timedelta(seconds=-5))
match.is_now(-5)

Before

passed, for any two different moments
a matcher that nothing matches

Now

ValueError: given tolerance arg must not be negative
ValueError: given delta arg must not be negative

A negative number as the tolerance was refused already, and so was a negative timedelta by match.close_to. Zero stays a tolerance, and the sentence now says so: it read must be positive.

match.is_now holds the two readings of a repeated hour apart

# the clocks went back: 02:30 comes twice, an hour apart, and now is the second
assert_that(first_reading).satisfies(match.is_now(0))

Before

passed

Now

AssertionFailure: Expected a datetime within 0:00:00 of now, but was <2026-11-01 02:30:00+02:00>.

Two datetimes on one zone object subtract by their wall clocks, which read the same in a repeated hour. The matcher now holds the value against the moment it is.

A numpy integer is measured as the int it holds

pixel = numpy.uint8(3)
assert_that(pixel).is_close_to(250, 10)
assert_that({"px": pixel}).is_equal_to({"px": numpy.uint8(250)}, tolerance=10)
assert_that(Fraction(1, 3)).is_less_than(numpy.int64(-(2**62)))
assert_that(Fraction(127, 3)).is_less_than(numpy.uint8(100))
assert_that(0).is_between(Fraction(-2, 3), numpy.int64(2**62))

Before

passed
passed
passed
AssertionFailure: Expected <127/3> to be less than <100>, but was not.
ValueError: given low arg must be less than given high arg

Now

AssertionFailure: Expected <3> to be close to <250> within tolerance <10>, but was not.
AssertionFailure: Expected <{'px': np.uint8(3)}> to be equal to <{'px': np.uint8(250)}>, but was not.
AssertionFailure: Expected <1/3> to be less than <-4611686018427387904>, but was not.
passed
passed

A numpy integer is fixed in width, and its arithmetic wraps at that width with a warning where an int grows: numpy.uint8(3) - 250 is 9. Closeness took the distance through that arithmetic, and a Fraction orders itself by multiplying its denominator by the other operand, so 3 * numpy.uint8(100) came out as 44. Closeness, the four relations, is_between and is_sorted now read a numpy integer as the int it holds.

A numpy float beside a numpy integer is read as the float it holds, so the pair is measured as Python measures an int against a float: numpy.int64(2**53 + 1) is greater than numpy.float64(2**53), where numpy calls the two equal. With no numpy integer among the operands a numpy float keeps its own arithmetic. A subclass of a numpy scalar is asked through its own operators and keeps its width.

Under -W error the same calls raised RuntimeWarning: overflow encountered in scalar subtract in place of a verdict. No warning comes out of them now.

Guide: Numbers

Two integers are close by their exact difference

assert_that(2**53 + 1).is_close_to(2**53, 0.0)
assert_that(2**52).is_close_to(2**52 + 3, 2.5)
assert_that(2**24 + 1).is_close_to(0, numpy.float32(2**24))

Before

passed
passed
passed

Now

AssertionFailure: Expected <9007199254740993> to be close to <9007199254740992> within tolerance <0.0>, but was not.
AssertionFailure: Expected <4503599627370496> to be close to <4503599627370499> within tolerance <2.5>, but was not.
AssertionFailure: Expected <16777217> to be close to <0> within tolerance <16777216.0>, but was not.

Closeness holds if the difference is within the tolerance or if either value lies in the window around the other, since a float rounds the three differently at the boundary. Under a float tolerance a window turns an integer into a float, and past 2**53 that rounds it onto its neighbour. The difference of two integers is exact, so it alone answers for them, for two Fractions and for one of each, and a numpy float tolerance is read as the float it holds. math.isclose(2**53 + 1, 2**53, rel_tol=0, abs_tol=0.0) is True, since it converts both to floats first. An integer against a float keeps its windows: -20 is close to -19.9 within 0.1.

match.close_to and is_equal_to(..., tolerance=) answer the same.

Guide: Numbers

A generator is read alike by every link of a chain

assert_that(iter([1, 2, 3])).contains(1).does_not_contain(1)
assert_that(iter([1, 2, 3])).contains(1).contains(3)

Before

passed
AssertionFailure: Expected <[]> to contain item <3>, but did not.

Now

AssertionFailure: Expected <[1, 2, 3]> to not contain item <1>, but did.
passed

A generator, an iterator, a zip or a map hands its items out once. The first assertion that walked one read it to its end, and the next link of the chain walked nothing: it passed where it asked for an absence and failed where it asked for a presence. The builder now keeps what such a value has handed out, and every link reads the same items. A walk that stops early takes no more than it did, so an endless generator is read as far as before.

val and value stay the iterator you passed. A matcher is handed the items. A predicate passed to satisfies() is handed the iterator itself, and what it takes the next link does not see, as before. Where a link had read a part of the value by then, a read past that part raises TypeError and names list(...), since what the predicate took can no longer be told.

The same holds under soft_assertions(), for a pivot such as first() and for each attempt of a poll. A value that can be read again, a list, a tuple, a set, a dict, is read as before.

Guide: Chaining

conforms_to_openapi finds a response by its range or default

# openapi: 3.0.3, GET /orders/{id} declares two responses: "2XX" (an order) and "default" (an error)
assert_that({"id": 7}).conforms_to_openapi(spec, "/orders/{id}", "get", status=200)
assert_that({"detail": "gone"}).conforms_to_openapi(spec, "/orders/{id}", "get", status=404)

Before

ValueError: Operation <GET /orders/{id}> declares no response <200>.
ValueError: Operation <GET /orders/{id}> declares no response <404>.

Now

passed
AssertionFailure: Expected the value to conform to the OpenAPI schema for <GET /orders/{id}> response <default>, but found 1 violation.
diff (openapi):
  $:
    - {'detail': 'gone'}
    + 'all required properties present'

A status is looked up as OpenAPI 3 declares responses: its own code first, then its range (200 under 2XX, 404 under 4XX), then default. A status none of the three covers is still refused with a ValueError. The failure names the response that was used. Swagger 2.0 has codes and default, and no ranges.

With no status= the order is 200, 201, 2XX, default, where it was 200, 201, default: an operation that declares 2XX and default is now checked against 2XX.

A status may be any int. status=HTTPStatus.NOT_FOUND was refused on Python 3.10, where the str of such a member is its name.

Guide: conforms_to_openapi

conforms_to_openapi checks date-time, int32 and int64

# the response schema declares `created` as format: date-time and `count` as format: int32
assert_that({"created": "yesterday", "count": 2**40}).conforms_to_openapi(spec, "/orders", "get")

Before

passed

Now

AssertionFailure: Expected the value to conform to the OpenAPI schema for <GET /orders> response <200>, but found 2 violations.
diff (openapi):
  $.count:
    - 1099511627776
    + 'int32 format'
  $.created:
    - 'yesterday'
    + 'date-time format'

jsonschema checks date-time only beside a package the json extra does not install, and int32 and int64 are OpenAPI's own formats that it does not know, so all three passed whatever the value held. conforms_to_openapi now checks them itself: a date-time by the grammar of RFC 3339, with a second of 60 taken only in the last minute of a UTC month, where a leap second can fall, and the two integer formats as their signed ranges.

The date-time check is the same one whether or not rfc3339-validator, the package that gives jsonschema its own, is installed. Where it is installed, three kinds of text change their verdict: a leap second such as 1990-12-31T23:59:60Z and a year of 0000 pass, which that package refused, and a text with a newline after it fails, which that package took.

date, ipv4, ipv6, uuid and regex were checked already. uri, hostname and duration have the next section to themselves, time the one after it, and email the one after that.

Guide: conforms_to_openapi

conforms_to_openapi reads nullable and writeOnly as OpenAPI 3.0.3 words them

# openapi: 3.0.3, the response schema of GET /accounts/{id}:
#   required: [id, password]
#   properties:
#     id:       {type: integer}
#     password: {type: string, writeOnly: true}
#     priority: {type: string, nullable: true, enum: [low, high]}
assert_that({"id": 7}).conforms_to_openapi(spec, "/accounts/{id}", "get")
assert_that({"id": 7, "password": "x", "priority": None}).conforms_to_openapi(spec, "/accounts/{id}", "get")

Before

AssertionFailure: Expected the value to conform to the OpenAPI schema for <GET /accounts/{id}> response <200>, but found 1 violation.
diff (openapi):
  $:
    - {'id': 7}
    + 'all required properties present'
passed

Now

passed
AssertionFailure: Expected the value to conform to the OpenAPI schema for <GET /accounts/{id}> response <200>, but found 1 violation.
diff (openapi):
  $.priority:
    - None
    + "one of ['low', 'high']"

Both verdicts were the wrong way round for a response under OpenAPI 3.0.

A property marked writeOnly: true is not asked for by required: 3.0.3 has the required of such a property "take effect on the request only". The mark is read through a $ref, and one that did arrive is validated like any other property.

nullable: true adds null to the type beside it and leaves every other keyword as written, so an enum lets null through only where it lists it. The rewrite used to add null to the enum as well. With no type beside it the keyword does nothing now, so {nullable: true, enum: [a]} refuses null too.

One reading stays wider than the text on purpose: nullable: true on a schema that is a $ref, an allOf, an anyOf or a oneOf still allows null, which is how a nullable reference is usually written. It lets null past the composition and no further. What else the schema holds is still asked of it, so {nullable: true, allOf: [...], enum: [open, closed]} refuses null, which it used to take, and takes it once the enum lists it. strict_nullable=True leaves that reading out, and has a section below.

Both keywords are read on the schemas the response reaches and no longer on the whole document as data. An enum member that looks like a schema, {"nullable": true} say, used to be rewritten with the rest and stopped matching the very value it lists.

Swagger 2.0's x-nullable reads as it did, and so do 3.1 and 3.2, where writeOnly is an annotation.

Guide: conforms_to_openapi

conforms_to_openapi checks uri, hostname and duration

# the response schema declares `self` as format: uri, `host` as format: hostname and `ttl` as format: duration
assert_that({"self": "/orders/7", "host": "api_internal", "ttl": "one day"}).conforms_to_openapi(spec, "/orders", "get")

Before

passed

Now

AssertionFailure: Expected the value to conform to the OpenAPI schema for <GET /orders> response <200>, but found 3 violations.
diff (openapi):
  $.host:
    - 'api_internal'
    + 'hostname format'
  $.self:
    - '/orders/7'
    + 'uri format'
  $.ttl:
    - 'one day'
    + 'duration format'

jsonschema checks each of the three only beside a package of its own, and the json extra installs none of them, so all three passed whatever the text held. conforms_to_openapi now checks them itself, each by the grammar its standard gives:

Format Read as Passes Fails
uri RFC 3986, with its scheme https://api.example.com/orders/7, urn:isbn:0451450523, http://[2001:db8::7]/ /orders/7, which is a uri-reference. http://example.com/a b. A non-ASCII letter, which makes it an iri
hostname RFC 1123: labels of ASCII letters, digits and hyphens, 63 characters each and 253 in all. Under 3.1 an xn-- label is a valid A-label too api.example.com, localhost, xn--nxasmq6b api_internal, -a.example, example.com. with its dot after it. Under 3.1, xn--X
duration the grammar RFC 3339 gives for it P1DT12H, PT30M, P2W one day, PT0.5S, P1Y2D, -P1D

The duration grammar is narrower than ISO 8601, and that is the format as JSON Schema defines it: no fraction, no sign, weeks alone, and a unit followed only by the next smaller one. Its letters are taken in either case, as the grammar has them: p1dt12h is a duration. A service that writes half a second as PT0.5S does not meet a schema that declares format: duration.

Under OpenAPI 3.1 a label of a host name that opens xn-- is held to IDNA 2008 as well, as JSON Schema 2020-12 has it: xn--nxasmq6b.example passes and xn--X.example fails. The idna package judges it, which the json extra now installs, by the Unicode tables of the release installed. Under OpenAPI 3.0 and Swagger 2.0 the format is the syntax of RFC 1123 alone, and both pass.

The three checks are the same whether or not the package that gives jsonschema its own is installed, so a text gets one verdict in every environment. Where one is installed, a few kinds of text change their verdict, each to what the grammar says: a text with a newline after it fails under all three, a host name with a dot after its last label fails, a duration with a sign, a fraction, weeks beside another unit or a unit left out between two (P1Y2D) fails, and an IPv6 host whose IPv4 end holds a leading zero ([::ffff:01.2.3.4]) fails.

uri-reference, iri, idn-hostname, json-pointer, uri-template and the other formats jsonschema checks beside a package of their own are still checked only where that package is installed. jsonschema[format-nongpl] brings them all.

Guide: conforms_to_openapi

conforms_to_openapi reads a time as RFC 3339 writes it

# the response schema declares `opens` as format: time
assert_that({"opens": "10:00:00Z"}).conforms_to_openapi(spec, "/shops", "get")
assert_that({"opens": "10:00:00"}).conforms_to_openapi(spec, "/shops", "get")

Before

AssertionFailure: Expected the value to conform to the OpenAPI schema for <GET /shops> response <200>, but found 1 violation.
diff (openapi):
  $.opens:
    - '10:00:00Z'
    + 'time format'
passed

Now

passed
AssertionFailure: Expected the value to conform to the OpenAPI schema for <GET /shops> response <200>, but found 1 violation.
diff (openapi):
  $.opens:
    - '10:00:00'
    + 'time format'

Both verdicts were the wrong way round. The format time is the full-time of RFC 3339, which has an offset: 10:00:00Z, 10:00:00+02:00. jsonschema's default checker holds the rule of JSON Schema Draft 3 under that name, HH:MM:SS and no more, whatever is installed, so it refused every time a standard service writes and took the one form the standard does not have.

conforms_to_openapi now checks the format itself, with the reading it already gives the time inside a date-time. A fraction of a second is of any length. A second of 60 is taken in the last minute of a day counted in UTC, which is where a leap second falls: 23:59:60Z and 01:29:60+01:30 pass.

A spec that declares format: time for a local time with no offset, 14:30:00, now fails on it. That value is no time by the format. Leave the format out for it, or send the offset.

Guide: conforms_to_openapi

conforms_to_openapi divides a multipleOf as the numbers are written

# the response schema declares `total` as a number with multipleOf: 0.01
assert_that({"total": 19.99}).conforms_to_openapi(spec, "/orders/{id}", "get")

Before

AssertionFailure: Expected the value to conform to the OpenAPI schema for <GET /orders/{id}> response <200>, but found 1 violation.
diff (openapi):
  $.total:
    - 19.99
    + '19.99 is not a multiple of 0.01'

Now

passed

jsonschema divides the two floats and asks whether the quotient is whole, and 19.99 / 0.01 is 1998.9999999999998. So a price failed the multipleOf: 0.01 it meets, and so did 0.3 under 0.1 and 0.07 under 0.01. JSON Schema has the keyword hold where the division "results in an integer", which it says of the numbers and not of the floats nearest them.

conforms_to_openapi now reads a float as the decimal it prints as and divides exactly. 19.995 still fails multipleOf: 0.01, and 0.30000000000000004 is no multiple of 0.1. Digits a float does not keep are gone before the check sees the number: 1.0000000000000001 is 1.0 once parsed. Where they matter, parse with json.loads(text, parse_float=decimal.Decimal), and a Decimal is divided exactly.

The reading holds under every dialect, and for a 3.1 schema that declares a dialect of its own by $schema, which jsonschema hands to another of its classes.

A multipleOf that is no number above zero is refused with a ValueError naming it, which is how every draft has the keyword. For multipleOf: 0 the check used to raise ZeroDivisionError, a negative step was taken by its size, and an infinite one passed every number.

Guide: conforms_to_openapi

conforms_to_openapi checks an email as RFC 5321 writes a mailbox

# the response schema declares `email` as format: email
assert_that({"email": "ann lee@shop.example"}).conforms_to_openapi(spec, "/orders/{id}", "get")

Before

passed

Now

AssertionFailure: Expected the value to conform to the OpenAPI schema for <GET /orders/{id}> response <200>, but found 1 violation.
diff (openapi):
  $.email:
    - 'ann lee@shop.example'
    + 'email format'

jsonschema asks a text for an @ and no more, so @, ann@ and ann lee@shop.example passed for an address. The format is the Mailbox of RFC 5321, and conforms_to_openapi now checks it by that grammar:

Part Read as Passes Fails
local part atoms joined by single dots, or a quoted string ann.lee, o'brien+tag, "ann lee" ann lee, ann..lee, .ann, ann.
domain a host name, 63 characters a label and 253 in all shop.example, localhost, 10.0.0.1 shop_1.example, shop.example., -shop.example
address in brackets an IPv4 address, or IPv6: and an IPv6 one [10.0.0.1], [IPv6:2001:db8::7] [10.0.0.256], [::1] with no tag

The grammar is ASCII's: an address with a letter past it, josé@shop.example, fails under email and belongs to idn-email. The local part has no length in the grammar, and the 64 characters SMTP allows it are not held.

Guide: conforms_to_openapi

conforms_to_openapi with strict_nullable=True

# the response schema declares `owner` as {nullable: true, $ref: "#/components/schemas/User"}
assert_that({"owner": None}).conforms_to_openapi(spec, "/orders/{id}", "get")
assert_that({"owner": None}).conforms_to_openapi(spec, "/orders/{id}", "get", strict_nullable=True)

Before

passed
TypeError: JsonMixin.conforms_to_openapi() got an unexpected keyword argument 'strict_nullable'

Now

passed
AssertionFailure: Expected the value to conform to the OpenAPI schema for <GET /orders/{id}> response <200>, but found 1 violation.
diff (openapi):
  $.owner:
    - None
    + 'type object'

OpenAPI 3.0.3 has nullable: true add null to the type beside it, "only if type is explicitly defined within the same Schema Object". Beside a $ref, an allOf, an anyOf or a oneOf with no type of its own it does nothing by that text. conforms_to_openapi still allows null there by default, since that is how a nullable reference is usually written.

strict_nullable=True holds the keyword to the text. null is then asked of what else the schema holds, as it would be with no mark there: a $ref to an object refuses it and one to a nullable object takes it, an anyOf takes it where a branch does, and a oneOf where exactly one does. Swagger 2.0's x-nullable and OpenAPI 3.1 are read the same either way.

Guide: conforms_to_openapi

conforms_to_openapi refuses a keyword OpenAPI 3.0 does not have

# openapi: 3.0.3, the response schema of GET /orders/{id}:
#   type: object
#   properties:
#     status: {const: placed}
assert_that({"status": "cancelled"}).conforms_to_openapi(spec, "/orders/{id}", "get")

Before

passed

Now

ValueError: Schema <#/paths/~1orders~1{id}/get/responses/200/content/application~1json/schema/properties/status> holds <const>, which OpenAPI 3.0 does not have, so the response would not be held to it as written. Leave it out, or move the spec to OpenAPI 3.1 as a whole: with the version line alone changed, `nullable` and a boolean exclusive bound are what is not read.

OpenAPI 3.0 and Swagger 2.0 carry an older JSON Schema than 3.1 does. A keyword that came later is not an error there, it is passed over, so a 3.0 schema that said const: placed took any status at all. Nothing in the run showed it: the assertion passed.

conforms_to_openapi now refuses such a schema with a ValueError that names the schema by its pointer and the keyword: const, contains, propertyNames, if, dependentRequired, dependentSchemas, unevaluatedProperties, unevaluatedItems, prefixItems, $dynamicRef and $recursiveRef, and exclusiveMinimum or exclusiveMaximum written as a number, which the older schema reads as a flag beside minimum or maximum.

It is an error in the spec and not a failure of the response, so it is raised and not collected: under soft_assertions() or check() it comes out as it is.

Only the schemas the response reaches are read, through every $ref the validator follows. A schema elsewhere in the document, an example, and a keyword beside a $ref, which 3.0 has ignored, are left alone. A spec under 3.1 or 3.2 reads every one of these keywords, as before.

A $schema inside a 3.0 or 2.0 document no longer changes how a schema is read. jsonschema switched to the dialect such a line named, so one schema of a 3.0 document could be read as 2020-12 and its neighbours as 3.0. The document is one dialect now, which is what OpenAPI 3.0 has: there is no $schema in its Schema Object.

The fix is in the spec: say it as 3.0 does (enum: [placed] for const: placed, minimum with exclusiveMinimum: true), or move the document to 3.1 as a whole. Changing the version line alone trades one unread keyword for another: under 3.1 nullable and a boolean exclusiveMinimum are what is not read.

Guide: conforms_to_openapi

A list is no tuple under ignore= or include=

assert_that([1, 2]).is_equal_to((1, 2), ignore="b")
assert_that(rows).is_equal_to(tuple(expected_rows), ignore="updated_at")

Before

passed
passed

Now

AssertionFailure: Expected <[..]> to be equal to <(..)> ignoring keys <b>, but was not.
AssertionFailure: Expected <[..]> to be equal to <(..,)> ignoring keys <updated_at>, but was not.

With no option the pair failed, as it did one level down and under strict_types=True. At the top a key option compared the two element by element and never asked what they were. It asks now what == asks: a list and a tuple differ unless the __eq__ of one of them takes the other. match.equal_to(..., ignore=) answers the same.

The two readings of a repeated hour are an hour apart

# the clocks of Europe/Berlin go back at 03:00 on 25 October 2026, so 02:30 comes twice
first = datetime(2026, 10, 25, 2, 30, tzinfo=ZoneInfo("Europe/Berlin"))
hour_later = datetime(2026, 10, 25, 1, 30, tzinfo=timezone.utc)
assert_that(first).is_equal_to_ignoring_seconds(hour_later)

Before

passed

Now

AssertionFailure: Expected <2026-10-25 02:30+02:00> to be equal to <2026-10-25 02:30+01:00>, but was not.

is_equal_to_ignoring_seconds and is_equal_to_ignoring_milliseconds read the other moment on the value's clock and compare what the clock shows. In a repeated hour it shows one time for two moments. The two are now also held to be one reading of it, and the message prints the offsets where the two read alike. Two datetimes on one zone object are compared by their wall clocks as before, which is how == compares them. is_equal_to_ignoring_time is unchanged: both readings are one date.

A matcher refuses an empty prefix, suffix or pattern

assert_that(repo).matches_structure({"html_url": match.starts_with(base_url)})  # base_url came out empty

Before

passed, whatever html_url held

Now

ValueError: given prefix arg must not be empty

Every text starts and ends with the empty text, and the empty pattern is found in every text. starts_with, ends_with and matches_regex under match took one, where the assertions of the same names refuse it. They refuse it when they are built, in the assertion's words, for str, bytes and bytearray. match.all_of() and match.any_of() with no matcher answer as before.

Guide: Matchers

A key left out by a path no longer fails under strict_types=True

assert_that({"user": {"id": 7}}).is_equal_to(
    {"user": {"id": 7, "seen_at": None}}, ignore=[("user", "seen_at")], strict_types=True
)

Before

AssertionFailure: Expected <{'user': {..}}> to be equal to <{'user': {..}}> ignoring keys <user.seen_at>, but was not.

Now

passed

Without strict_types=True the comparison passed. With it, the two nested dicts were first asked whether their keys are of the same types, and that question called them different as soon as one had a key the other lacked, the key left out included. It is asked now of the keys the path keeps.

strict_types=True and a comparator look inside an object, a deque and a tuple key

class User:
    def __init__(self, active):
        self.active = active

    def __eq__(self, other):
        return type(other) is User and self.active == other.active


assert_that({"user": User(1)}).is_equal_to({"user": User(True)}, strict_types=True)
assert_that(deque([1, 2])).is_equal_to(deque([True, 2]), strict_types=True)
assert_that({(1, 2): "a"}).is_equal_to({(True, 2): "a"}, strict_types=True)

Before

passed
passed
passed

Now

AssertionFailure: Expected <{'user': User(active=1)}> to be equal to <{'user': User(active=True)}>, but was not.
compared with strict_types=True
the values on both sides are equal, and only their types differ
diff (dict):
  user.active:
    - 1
    + True
AssertionFailure: Expected <deque([1, 2])> to be equal to <deque([True, 2])>, but was not.
compared with strict_types=True
the values on both sides are equal, and only their types differ
diff (sequence):
  [0]:
    - 1
    + True
AssertionFailure: Expected <{(1, 2): 'a'}> to be equal to <{(True, 2): 'a'}>, but was not.
compared with strict_types=True
diff (dict):
  (1, 2):
    - (1, 2)
    + (True, 2)

strict_types=True holds "at any depth", and three kinds of value stopped it: an object compared by an == of its own, a collections.deque, and a tuple or a frozenset used as a dict key or a set member. A comparator stricter than == stopped at the first two as well, and so did a matcher built with either option. With an unrelated ignore= added, the first comparison already failed.

An object is still asked its own == first, and that == stays the judge of what the two hold. Where it holds them equal, each attribute both sides hold equal is checked as any other node is: its type, a comparator that owns it, and what it holds in turn. An attribute the two hold apart, an attribute only one side has, and an object that keeps anything outside its __dict__ (one with __slots__, or a deque of a class of your own) are left to that ==. The options can only add to it: two such objects it holds apart stay apart under a tolerance or a forgiving comparator, as before.

A tolerance= reaches the items of a deque too, so two deques of floats within the tolerance now pass:

assert_that(deque([1.0004])).is_equal_to(deque([1.0]), tolerance=0.001)

Before

AssertionFailure: Expected <deque([1.0004])> to be equal to <deque([1.0])>, but was not.
compared with tolerance=0.001
diff (scalar):
  .:
    - deque([1.0004])
    + deque([1.0])

Now

passed

Guide: What the walk takes apart

The very NaN a value holds is found by contains_sequence and contains_in_order

nan = float("nan")
assert_that([1, nan, 2]).contains_sequence(nan)
assert_that([1, nan, 2]).contains_in_order(1, nan)

Before

AssertionFailure: Expected <[1, nan, 2]> to contain sequence <nan>, but did not. No run started with <nan>.
AssertionFailure: Expected <[1, nan, 2]> to contain <1, nan> in order, but <nan> did not follow after <1>.

Now

passed
passed

contains and contains_exactly passed for it already, as nan in [1, nan, 2] is true: an object is itself before == is asked. The two sequence assertions asked == alone. Another NaN is still not found.

A file is no child of itself

assert_that("/srv/app/settings.toml").is_child_of("/srv/app/settings.toml")

Before

passed

Now

AssertionFailure: Expected file </srv/app/settings.toml> to be a child of </srv/app/settings.toml>, but was not.

An awaited chain that only describes is refused

await assert_that(fetch).eventually().described_as("the order")

Before

passed, with nothing asserted

Now

TypeError: no assertion was called on this eventually() chain, so awaiting it would wait for nothing

The bare chain was refused that way already. described_as sets the words of a failure and asserts nothing.

Guide: Async assertions

An empty path of keys and a delta past a timedelta

assert_that(row).is_equal_to(expected, ignore=())
match.is_now(float("inf"))

Before

IndexError: tuple index out of range
OverflowError: cannot convert float infinity to integer

Now

compared with no key left out, as ignore=[] is
ValueError: given delta arg must be a number of seconds a timedelta holds, but was <inf>

A mapping that is no dict is read by its keys beside a record

assert_that(MappingProxyType({"host": "a", "port": 81})).is_equal_to(Settings("a"), ignore="port")

Before

TypeError: ignore/include requires dict-like objects or objects with introspectable fields (dataclass, namedtuple, attrs, Pydantic model, or object with __dict__)

Now

passes

A MappingProxyType was refused as not dict-like, and a ChainMap was read through the attribute it keeps its maps in. Under ignore= and include= any mapping that is not a record is its keys.

has_<name>() fails as is_equal_to on the value it read

assert_that(User(7, "ann")).has_id("7")
assert_that({"address": {"city": "Oslo", "zip": "0150"}}).has_address({"city": "Paris", "zip": "0150"})

Before

AssertionFailure: Expected <7> to be equal to <7> on attribute <id>, but was not.

AssertionFailure: Expected <{'city': 'Oslo', 'zip': '0150'}> to be equal to <{'city': 'Paris', 'zip': '0150'}> on key <address>, but was not.

Now

AssertionFailure: Expected <7:int> to be equal to <7:str> on attribute <id>, but was not.
diff (scalar):
  .:
    - 7
    + '7'

AssertionFailure: Expected <{'city': 'Oslo', ..}> to be equal to <{'city': 'Paris', ..}> on key <address>, but was not.
diff (dict):
  city:
    - 'Oslo'
    + 'Paris'

failure.actual is the value read, failure.expected the operand. A NaN, a pair compared by identity and a difference of whitespace get the line is_equal_to gives them.

Two dicts are printed as is_equal_to prints them, the keys that match collapsed to .., as in the second failure above. Two lists or two plain tuples too long to print whole are collapsed the same way.

ids = list(range(25))
assert_that({"ids": ids}).has_ids([*ids[:17], 99, *ids[18:]])

Before

AssertionFailure: Expected <[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24]> to be equal to <[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 99, 18, 19, 20, 21, 22, 23, 24]> on key <ids>, but was not.

Now

AssertionFailure: Expected <[.., 17, ..]> to be equal to <[.., 99, ..]> on key <ids>, but was not.

Guide: Dynamic assertions on objects

A failed is_equal_to reaches the IDE's comparison window

assert_that(response.json()).is_equal_to(expected)

Before, as PyCharm's test runner reports it to the IDE:

##teamcity[testFailed name='test_orders' message='...' details='... assertpy2.AssertionFailure: Expected <{..}> to be equal to <{..}>, but was not. ...']

Now

##teamcity[testFailed name='test_orders' type='comparisonFailure' actual='{...}' expected='{...}' message='...' details='...']

PyCharm turns that message into its "Click to see difference" link, and the window shows the operand under Expected and the value under test under Actual, both whole. assertpy had the link through the text of its message, which AssertionFailure does not match. Seen in PyCharm 2026.3.

What is handed over is the pair the assertion compared.

assert_that(response.json()).is_equal_to(expected, ignore="updated_at")
assert_that(order).has_items(expected["items"])

The first hands over the two values without updated_at, at any depth, so a key the assertion passed over is in neither pane. The second hands over the value read under items and the operand.

Not handed over: an is_equal_to() under tolerance=, comparators=, strict_types= or ignore_null=, a negation, any other assertion, a soft_assertions() block, a group of several failures. The window compares two texts, as it does for a plain assert: 1 beside True shows there as a difference, two NaNs as none. Under pytest-xdist a runner that reports from the controller does not hear it. assertpy2_comparison = "off" turns it off.

Under ignore= or include= a record is handed over as the dict of its fields, so a pane lays it out line by line and a dict beside a record reads in the same shape.

Guide: The comparison window of an IDE

Under ignore= or include= the report section shows what was compared

assert_that({"page": 1, "city": "Oslo"}).is_equal_to({"page": 2, "city": "Paris"}, ignore="page")

Before

E   assertpy2.AssertionFailure: Expected <{'city': 'Oslo'}> to be equal to <{'city': 'Paris'}> ignoring keys <page>, but was not.
------------------------------ AssertionFailure -------------------------------
  actual:   {'page': 1, 'city': 'Oslo'}
  expected: {'page': 2, 'city': 'Paris'}

Now

E   assertpy2.AssertionFailure: Expected <{'city': 'Oslo'}> to be equal to <{'city': 'Paris'}> ignoring keys <page>, but was not.
------------------------------ AssertionFailure -------------------------------
  actual:   {'city': 'Oslo'}
  expected: {'city': 'Paris'}

On a long value the two rows are cut around the first place they part, and that place was the key left out. failure.actual and failure.expected stay the values you passed. Under pytest -vv the two views are printed whole. Beside tolerance=, comparators=, strict_types= or ignore_null= the rows stay the two values.

Guide: Rich pytest diffs

The diff of a failed contains prints its items as they are

assert_that([{"id": 1}, {"id": 2}]).contains({"id": 99})

Before

diff (contains):
  missing: {{'id': 99}}

Now

diff (contains):
  missing: {'id': 99}

Several items are separated by commas: missing: 3, 4. The diff of two sets keeps the braces of a set.

Asking whether a class compares by identity runs none of its code

class Equality:
    def __get__(self, instance, owner):
        if instance is None:
            owner.__eq__ = lambda self, other: False
            return object.__eq__
        return lambda other: True

class Token:
    __eq__ = Equality()

Token() == Token()                         # True
assert_that(Token()).is_equal_to(Token())

Before

AssertionFailure: Expected <<Token object at 0x...>> to be equal to <<Token object at 0x...>>, but was not.

Now

passed

all_fields_satisfy on a value that holds itself

assert_that(account(1, 5)).all_fields_satisfy(lambda value: isinstance(value, int))

Before

AssertionFailure: Expected all fields to satisfy a lambda predicate, but 1 field did not.
diff (match):
  owner: expected a lambda predicate, but was '<circular ref>'

Now

passed

Guide: Recursive field assertions

One verdict from the builder and the matcher under a key option

match.equal_to([1], ignore="note", strict_types=True).matches([1.0])

Before

True

Now

False
assert_that([[1.0, 2.0]]).is_equal_to([[1.4, 2.0]], ignore="note", tolerance=0.5)

Before

AssertionFailure: Expected item at index <0> to be equal to <[1.4, 2.0]>, but was <[1.0, 2.0]>.

Now

passed

Guide: Recursive comparison

Three chains ty read as Unknown are typed

def check(count: int | None) -> None:
    assert_that(count).is_not_none().is_greater_than("text")

Before

All checks passed!

Now

error[invalid-argument-type]: Argument to bound method `_RealNumberAssertion.is_greater_than` is incorrect

ty read the chain after is_not_none() as Unknown for an optional bool, int, float, list, tuple, set, frozenset, datetime or callable, so nothing after it was checked. It now lands on the same view as under mypy, Pyright and Pyrefly, where nothing changed.

The same held for a poll over an async def probe:

async def order_count() -> int: ...

await assert_that(order_count).eventually().starts_with("x")

Before

All checks passed!

Now

error[no-matching-overload]: No overload of bound method `_AsyncPoll.starts_with` matches arguments

And for what a call returned after warns():

def port() -> int: ...

assert_that(port).warns(DeprecationWarning).when_called_with().returned().value.upper()

Before

All checks passed!

Now

error[unresolved-attribute]: Object of type `int` has no attribute `upper`

ty in all three. mypy --strict, Pyright and Pyrefly refused them before, and nothing changed for those.

Guide: Typed narrowing with .value

A class written where a matcher was meant

assert_that({"id": 7}).matches_structure({"id": int})

Before

Expected <{'id': 7}> to match structure a mapping matching structure {id: <<class 'int'>>}, but at <id>: expected <<class 'int'>>, but was <7>.

Now

Expected <{'id': 7}> to match structure {id: <<class 'int'>>}, but at <id>: expected <<class 'int'>>, but was <7>.
at <id> the spec holds a class itself and the value is an instance of it: the matcher for an instance of a class is match.is_instance_of

A spec compares a value that is no matcher with ==, and a class is such a value. The line is said for the first place where the spec holds a class and the value there is an instance of it. It is not said for a union, a generic alias, or a class with a metaclass of its own.

Where the value is no instance of the class, the sentence says its type, as the matcher for an instance does:

assert_that({"id": "7"}).matches_structure({"id": int})

Before

... but at <id>: expected <<class 'int'>>, but was <7>.

Now

... but at <id>: expected <<class 'int'>>, but was <7> of type <str>.

A factory that takes a matcher says the same where it is handed a class:

match.each_item(str)

Before

TypeError: given matcher arg must be a Matcher, but was <<class 'str'>> (type)

Now

TypeError: given matcher arg must be a Matcher, but was <<class 'str'>> (type): the matcher for an instance of a class is match.is_instance_of

Guide: Structural matching

A verdict asked after a pivot is held to the value's kind under ty and mypy

assert_that([1, 2]).first().check().starts_with("x")

Before

All checks passed!

Now

error[no-matching-overload]: No overload of bound method `_CheckAnyValue.starts_with` matches arguments

ty. It read none of the restrictions on the view check() hands back for a value reached through a pivot, so a text, a key or a numeric assertion was taken on any value. mypy --strict, Pyright and Pyrefly refused these before.

mypy took a text assertion on a value that is one of two kinds, there and on a polling chain:

def count_or_label() -> int | str: ...

assert_that(count_or_label).eventually_sync().starts_with("a")

Before

Success: no issues found in 1 source file

Now

error: Invalid self argument "_SyncPoll[int | str]" to attribute function "starts_with"

mypy --strict. ty, Pyright and Pyrefly refused it on a polling chain before.

check().val and check().value after a pivot are typed as the value, where all four checkers read each as a call that hands back a verdict.

Guide: Typed narrowing with .value

val on check() and on not_ is the value where the subject is callable

def subject() -> int:
    return 7

assert_that(subject).check().val is subject
assert_that(subject).not_.val is subject

Before

False
False

Now

True
True

Both proxies wrapped anything callable as an assertion, the value included, so val over a function was a wrapper, and calling it called the function. value is handed back the same way.

A refusal's traceback under pytest is the line that wrote it

def test_tags():
    match.each_item(5)

Before

test_orders.py:5: in test_tags
assertpy2/matchers.py:578: in each_item
assertpy2/_matcher_impls.py:1407: in __init__
assertpy2/_matcher_impls.py:211: in _require_matcher_argument
assertpy2/_engine/_require.py:132: in refuse
E   TypeError: given matcher arg must be a Matcher, but was <5> (int)

Now

test_orders.py:5: in test_tags
E   TypeError: given matcher arg must be a Matcher, but was <5> (int)

pytest --tb=short. The same for a refused argument (assert_that("a").is_length("x")), a matcher operator and a predicate that hands back a coroutine. Only a refusal hides these frames: what your own matcher or predicate raises keeps the frames that asked it, and --full-trace shows every frame.

A failed matches_structure leaves out what matched

order = {"id": 7, "name": "Alice", "age": 15, "active": True, "email": "alice@example.com", "plan": "team"}

assert_that(order).matches_structure({"name": "Alice", "age": match.between(18, 120), "active": True})

Before

Expected <{'id': 7, 'name': 'Alice', 'age': 15, 'active': True, 'email': 'alice@example.com', 'plan': 'team'}> to match structure a mapping matching structure {name: <Alice>, age: a value between <18> and <120>, active: <True>}, but at <age>: expected a value between <18> and <120>, but was <15>.

Now

Expected <{.., 'age': 15, ..}> to match structure {.., age: a value between <18> and <120>, ..}, but at <age>: expected a value between <18> and <120>, but was <15>.

One wrong field of a record of 42 fields, asked with 21 matchers, was a sentence of 2 658 characters and is one of 192. The spec follows to match structure bare, where the sentence said to match structure a mapping matching structure. A model and an attrs instance are written under their class name: Customer(.., address={'city': 'Oslo', ..}, ..). The failure holds the whole value and the whole spec as it did, and pytest -vv prints them whole in the report section.

Guide: Structural matching

Behaviour changes

A failed matches_structure prints the value and the spec with what matched left out as .., and the spec bare after to match structure, so a test that pins its whole sentence changes. failure.actual, failure.expected and failure.diff are as they were
A chain ty read as Unknown: after is_not_none(), on a poll over an async def probe, on returned() after warns(), and every assertion on check() after a pivot is read with its types, so a misuse that type-checked under ty is reported
A text assertion on a value that is one of two kinds, under mypy is refused on check() after a pivot and on a polling chain
val and value on check() or not_ over a callable subject are the subject itself, where they were a wrapper that called it
is_equal_to on a list or tuple under ignore= or include= fails once for the two sequences. failure.actual and failure.expected are the sequences, paths start at the sequence, soft_assertions() collects one failure. Expected collection length <2>, but was <3>. and Expected item at index <1> to be equal to <9>, but was <2>. are gone
ignore= or include= on a subclass of a builtin container or a class with a slot compares it by its own ==, so two that carry the same attributes no longer pass. At the top it raises the TypeError a plain set raises
ignore= or include= on an exception reads its class (__class__), args and the attributes in its __dict__, where it read the __dict__ alone. Two with different messages, or of two classes, no longer pass unless args or __class__ is left out. An OSError, an exception group and a class with a slot are compared by their own ==. A dataclass, attrs or model exception is read by its declared fields, as before
A failed contains, contains_only, contains_exactly, contains_exactly_in_any_order, contains_only_once, is_in, is_subset_of or contains_value can name the closest row after its sentence, for records as for dicts, and carry a line under it about the one item not found. contains_key, contains_sequence, contains_in_order and contains_entry carry the line. contains_exactly and contains_exactly_in_any_order can end their sentence with how often one item is held and was asked for. A match= against the sentence keeps matching
A failed contains_only with one element left over and one item lacked that print the same prints <7:int> and <7:str> where it printed <7> twice
A failed contains_entry with one entry not found ends with Key <status> holds <'inactive'>. or There is no key <status>.
A failed contains_sequence counts a run that starts too late to fit: The longest run that matched was <1>. where it said No run started with <1>.
The closest element of a failed contains prints <missing> for a key one side lacks, where it printed None
The diff of a failed contains, contains_only, contains_exactly and their siblings prints missing: 3, 4 and extra: 2, where it wrapped the items in braces (missing: {3, 4}). str(failure) off pytest ends with that diff. failure.diff.entries do not change
The AssertionFailure report section of a failed is_equal_to under ignore= or include=, with no tolerance=, comparators=, strict_types= or ignore_null= beside them its two rows are the views compared, without the keys left out, also under pytest -vv. failure.actual and failure.expected do not change
A failed is_close_to, is_not_close_to, is_less_than, is_greater_than, their _or_equal_to forms, is_between, is_not_between on datetimes prints each datetime whole, microseconds and offset included, and a timedelta tolerance with its fraction. A naive datetime and a tolerance of whole seconds print as before
match.is_now on a datetime in the repeated hour of its zone no longer matches the other reading of that hour, an hour away
A MappingProxyType, a ChainMap or another mapping that is no dict, beside a record under ignore= or include= is compared by its keys. Before, TypeError, or for a ChainMap a comparison of its maps attribute
A value whose __class__ says it is a dict, a list or a tuple (a Mock(spec=dict) is one) under ignore= or include= is compared as the object it is. Before, AttributeError or TypeError from the comparison
A negative tolerance of is_close_to, is_not_close_to and match.close_to is refused with given tolerance arg must not be negative. The sentence read must be positive, though zero was taken
is_close_to and is_not_close_to on datetimes with a negative timedelta tolerance, and match.is_now with a negative delta raise ValueError. Before, the assertions compared and found nothing close, and the matcher matched nothing
A failed is_before, is_after, is_before_or_equal_to, is_after_or_equal_to, and for two datetimes a failed is_less_than, is_greater_than, their _or_equal_to forms, is_close_to or is_not_close_to can carry a line under its sentence with the distance between the two moments, exact to the microsecond
A failed starts_with, ends_with, contains on a text, their _ignoring_case twins, is_equal_to_ignoring_case, is_equal_to_ignoring_whitespace can carry a line under its sentence naming what alone keeps the relation from holding: whitespace at that end, case, the kind or amount of whitespace
A failed is_equal_to on a str or bytes subclass whose own strip() or replace() answers something else than its text no longer carries a whitespace line the text does not earn: the line is read off the text itself
A failed is_equal_to on two texts that differ only in the kind or amount of whitespace carries the line every difference here is one of the kind or amount of whitespace
A failed contains_key, contains on a mapping, contains_entry, and a has_<name>() asked for a name that is not there can carry a line under its sentence naming the key or attribute that reads almost the same
A failed has_<name>() on two dicts, or on two lists or two plain tuples too long to print whole collapses what matches to .., as is_equal_to does
A failed has_<name>() failure.actual is the value read, where it was the whole subject. failure.expected is the operand, also where the name is not there. failure.diff is set, and off pytest str() ends with it. Two values that print the same end with :Class
A failed matches_structure or match.structure on a leaf that prints the same as the value prints expected <7:str>, but was <7:int>
ignore= or include= on a dict that holds itself can fail where it passed
ignore= or include= on a dataclass, attrs instance or model against a dict, each holding an equal record passes, in either order: at the top, as an element of a sequence, under a path, and in match.equal_to
A path on a dict beside a dataclass, attrs instance or model that holds the same passes
ignore= or include= on two records holding an OrderedDict each fails where the order differs, as two OrderedDicts do
strict_types=True with ignore= or include= fails on a sequence whose elements differ in type (match.equal_to) and on a record holding a dict of a class of your own beside a plain dict
A failed is_equal_to always carries a diff with an entry. Where failure.diff was None or empty there is one entry holding the pair, and off pytest str() ends with it
An equality diff, and all_fields_satisfy hold no '<circular ref>'
A failure under ignore= or include= prints a dataclass, attrs instance, named tuple or model as Row(name='a'), where it printed {'name': 'a'}, and a dict of a class of your own as Headers({'etag': 'a'})
Two sides that print the same and are of two classes end with :Class in a diff row, in the line soft_assertions() keeps and in the closest element of contains
A failed comparison can carry more lines under its sentence: a difference here prints the same on both sides, ... (second under a NaN line), all 40 differences here are at <[*].updated_at>, and for moved elements that print the same ..., and their repr does not show which is which. A match= against the sentence keeps matching
A diff row of two long values that are not text is cut around their first difference, where each side was cut at 400 characters
A value, an operand, a pattern or a list of items past 4000 characters, in any assertion or matcher prints its first 3000 and last 1000 with ... (N more chars) ... between. failure.actual and failure.expected hold the values whole
starts_with, ends_with, their case-insensitive twins, is_equal_to_ignoring_case, is_equal_to_ignoring_whitespace and has_<name>() on two long texts cut both around the place they part: (N more chars) ... ahead, ... (N more chars) past
A row soft_assertions() keeps for a diff is capped at 4000 characters a side
is_divisible_by(10**5000) fails as an assertion, where building its message raised ValueError: Exceeds the limit (4300 digits)
A failed is_equal_to without tolerance=, comparators=, strict_types= or ignore_null=, and a failed has_<name>(), under the pytest plugin calls pytest_assertrepr_compare with op="==" and the pair compared: the value and the operand, under ignore= or include= their copies without the keys left out. Every listener registered for that hook runs, pytest's own included, whose result is not used. assertpy2_comparison = "off" turns it off
The line these values compare with object's __eq__, ... is also said where a metaclass spells __mro__ or __dict__ of its own, and is not said of a class whose namespace holds a key that is not an exact str
Step.kind can be "json": code that handles every kind takes step.value there, as at item
is_close_to, is_not_close_to, match.close_to, is_equal_to(..., tolerance=), is_less_than, is_greater_than, their _or_equal_to forms, is_between and is_sorted on a numpy integer read it as the int it holds, and a numpy float beside it as the float it holds. A verdict that rested on arithmetic wrapping at the integer's width changes, and no RuntimeWarning comes out of the comparison. numpy.int64(2**53 + 1) is greater than numpy.float64(2**53), where numpy calls the two equal. A subclass of a numpy scalar keeps its own operators
is_close_to, is_not_close_to, match.close_to and is_equal_to(..., tolerance=) on two integers, two Fractions or one of each answer by the exact difference alone, and hold it against the float a numpy float tolerance holds. 2**53 + 1 is no longer within 0.0 or 0.5 of 2**53, where math.isclose says it is. An integer against a float is measured as before
A chain of assertions over a generator, an iterator, a zip or a map reads the same items at every link, where the links after the first read nothing. A second link that passed over nothing can fail, and one that failed over nothing passes. val and value stay the iterator. After a part of the value was read and a predicate in satisfies() was then handed the iterator, the next link that reads raises TypeError
conforms_to_openapi on a schema that declares format: date-time, int32 or int64 checks the format, so a response that carried "yesterday" or 2**40 there fails. A date-time needs its offset and its T: 2026-01-01 10:00:00 fails. With rfc3339-validator installed, a leap second at the end of a UTC month and a year of 0000 now pass, and a text ending in a newline fails
conforms_to_openapi under OpenAPI 3.0 on nullable: true beside an enum passes null only where the enum lists it. With no type beside it and no $ref, allOf, anyOf or oneOf, nullable does nothing, so such an enum refuses null as well. Add null to the enum where the API does send it
conforms_to_openapi under OpenAPI 3.0 on a property that is required and writeOnly does not ask a response for it
conforms_to_openapi on a schema that declares format: uri, hostname or duration checks the format, so a response that carried /orders/7, api_internal or PT0.5S there fails. With the package that gave jsonschema its own check installed, a text ending in a newline, a host name ending in a dot, a duration with a sign or a fraction, and an IPv6 host with a leading zero in its IPv4 end now fail
conforms_to_openapi under OpenAPI 3.0 or Swagger 2.0 on a response schema that holds a keyword of a later JSON Schema, or an exclusive bound written as a number raises ValueError naming the schema and the keyword, where it validated with the keyword passed over. The spec has to say it as 3.0 does or move to 3.1
conforms_to_openapi under OpenAPI 3.0 or Swagger 2.0 on a schema that declares a $schema of a later JSON Schema reads it as the rest of the document, where jsonschema read it by the dialect it named. A later keyword in it is refused like any other, and a keyword beside a $ref under it is ignored
conforms_to_openapi on a schema that declares format: time holds the value to RFC 3339: 10:00:00Z passes, where it failed, and a time with no offset such as 14:30:00 fails, where it passed
conforms_to_openapi on a schema that declares format: email holds the value to the mailbox of RFC 5321: @, ann lee@shop.example and an address with a letter past ASCII fail, where they passed
conforms_to_openapi on a number held to a multipleOf divides the numbers as they are written: 19.99 passes multipleOf: 0.01, where it failed. A multipleOf that is no number above zero raises ValueError, where a zero raised ZeroDivisionError, a negative one was taken by its size and an infinite one passed every number
conforms_to_openapi under OpenAPI 3.1 on a host name with a label that opens xn-- holds the label to IDNA 2008, so xn--X fails. The json extra installs idna for it. Without that package such a host name raises ImportError naming the extra
conforms_to_openapi under OpenAPI 3.0 on nullable: true beside an allOf, anyOf or oneOf still asks null of what else the schema holds: an enum there passes null only where it lists it
conforms_to_openapi with a status= the operation declares only under a range or under default validates against that response, where it raised ValueError. Code that caught the error to learn a status is undeclared now gets a verdict
conforms_to_openapi with no status= on an operation that declares 2XX and default and neither 200 nor 201 validates against 2XX, where it used default
is_equal_to and match.equal_to with ignore= or include= on a list against a tuple fail, as they do with no option. A list whose own __eq__ takes a tuple keeps passing
is_equal_to and match.equal_to with strict_types=True or comparators= on an object compared by an == of its own look at each attribute both sides hold equal: its type, a comparator that owns it, and what it holds. A pair that passed can fail. An attribute that == holds apart, one only one side has, and an object with __slots__ stay with that ==
is_equal_to and match.equal_to with strict_types=True, comparators= or tolerance= on two collections.deque compare them item by item, so an item of another type fails and items within the tolerance pass. A deque of a class of your own stays with its ==
is_equal_to and match.equal_to with strict_types=True on a tuple or a frozenset used as a dict key or a set member read it all the way in, so {(1, 2): "a"} against {(True, 2): "a"} fails. A record used as a key, and a tuple or a frozenset of a class with an == of its own, are compared by class alone
is_equal_to_ignoring_seconds and is_equal_to_ignoring_milliseconds on two moments in two zones fail for the two readings of a repeated hour, and print the offsets where both read alike
match.starts_with, match.ends_with and match.matches_regex given an empty str, bytes or bytearray raise ValueError when built, where they matched every text
is_child_of given the file itself fails
is_equal_to with strict_types=True and an ignore= or include= path into a nested mapping no longer fails for a key the path leaves out
contains_sequence and contains_in_order find the very object the value holds before == is asked, so a NaN held is found
An awaited eventually() chain that holds described_as and no assertion raises TypeError, as the bare chain does
ignore=() and include=() name nothing, as an empty list does. They raised IndexError
match.is_now with a delta that is infinite, not a number, or past a timedelta raises ValueError
Off pytest, str() of an exact=True failure ends with its diff, one line per entry in soft_assertions()
exact=True fails with cannot be checked on three inputs that passed, shown below

Several TypedDicts with no Literal field to tell them apart: a clean payload passes, a key the built dict lost fails.

class Point(TypedDict):
    x: int

class Other(TypedDict):
    y: int

class Either(BaseModel):
    f: Point | Other

assert_conforms({"f": {"x": 1, "extra": 2}}, Either, exact=True)

Before

passed

Now

AssertionFailure: Expected <{'f': {'x': 1, 'extra': 2}}> to conform exactly to <Either>, but <f> cannot be checked: it holds a key the dict built from it does not, and its declared types do not say which built it
diff (match):
  f: expected cannot be checked: it holds a key the dict built from it does not, and its declared types do not say which built it, but was {'x': 1, 'extra': 2}

Keys merged by coercion over TypedDict values fail, clean or not.

class Keyed(BaseModel):
    f: dict[int, Point]

assert_conforms({"f": {"1": {"x": 1}, "01": {"x": 2}}}, Keyed, exact=True)

Before

passed

Now

AssertionFailure: Expected <{'f': {'1': {'x': 1}, '01': {'x': 2}}}> to conform exactly to <Keyed>, but <f> cannot be checked: validation changed its size, 2 keys became 1
diff (match):
  f: expected cannot be checked: validation changed its size, 2 keys became 1, but was {'1': {'x': 1}, '01': {'x': 2}}

Removed extras typed as a union of two or more types fail, clean or not.

class Tag(BaseModel):
    name: str

class Link(BaseModel):
    url: str

class Mixed(BaseModel):
    model_config = ConfigDict(extra="allow")
    __pydantic_extra__: dict[str, Tag | Link]
    id: int

    @model_validator(mode="after")
    def keep_no_extras(self):
        self.__pydantic_extra__.clear()
        return self

assert_conforms({"id": 1, "primary": {"name": "a"}}, Mixed, exact=True)

Before

passed

Now

AssertionFailure: Expected <{'id': 1, 'primary': {'name': 'a'}}> to conform exactly to <Mixed>, but <the payload> cannot be checked: the model no longer holds 1 of the extras it was sent
diff (match):
  .: expected cannot be checked: the model no longer holds 1 of the extras it was sent, but was {'id': 1, 'primary': {'name': 'a'}}

Off pytest, str() of an exact=True failure ends with its diff:

Before

Expected <{'id': 1, 'customer': {'id': 7, 'loyalty_tier': 'gold'}, 'promo_code': 'SPRING'}> to conform exactly to <Order>, but it carries 2 undeclared field(s) the model does not declare: ['customer.loyalty_tier', 'promo_code']

Now

Expected <{'id': 1, 'customer': {'id': 7, 'loyalty_tier': 'gold'}, 'promo_code': 'SPRING'}> to conform exactly to <Order>, but it carries 2 undeclared field(s) the model does not declare: ['customer.loyalty_tier', 'promo_code']
diff (match):
  customer.loyalty_tier: - 'gold'
  promo_code: - 'SPRING'

Guide: Selective comparison, Some failures say why, not only what, What a failure exposes, Contract drift with exact=True, Paths a program can follow

Everything else

  • test: conforms_to_openapi held to an independent validator, openapi-schema-validator, over generated schemas and values, with each place the two are meant to part named beside both verdicts
  • test: pyright's report is read off its output stream alone, so a line on its error stream no longer fails the typing tests with Extra data
  • build: a dependency group oracle for that validator and for the three packages the format checks are compared with, installed in the one CI cell that installs every integration
  • test: the uri, hostname and duration checks compared with the packages jsonschema asks, over 93 000 texts one or two parts away from a valid one
  • test: the uri check held to appendix A of RFC 3986 written out production by production, on every interpreter of the matrix
  • test: what conforms_to_openapi checks of its own held to the official JSON-Schema-Test-Suite, 424 cases of seven formats and of multipleOf, laid into the repository with its licence
  • test: the email check held to section 4.1.2 of RFC 5321 written out production by production
  • test: import assertpy2 held to loading none of the modules the code defers with a measured cost beside the import
  • test: the law that a value which holds itself is compared as the tree it unfolds into says where its paths are not the tree's: under two lists aligned on a print that stops at [...]
  • build: polars 2.0.0 in the lock
  • docs: how tolerance= reaches a plain object once ignore= or include= stands beside it
  • perf: conforms_to_openapi copies the part of the spec a check reads and no longer the whole document on every call. Against a spec of 4 MB a check took 30 ms and takes 0.2 ms
  • build: the json extra installs idna, which judges the xn-- labels of a host name under OpenAPI 3.1
  • docs: what a failed comparison of two large values costs, beside the recipe for comparing a part of them
  • docs: a recipe for rows that arrive in any order: sort both sides by their key, then is_equal_to(..., ignore=...)
  • test: the sentence of a failed matches_structure read back from its text and held to what lies on the way to its mismatches
  • docs: a table of what exact=True reads and what it does not see, and how it differs from pydantic's per-call extra="forbid"
  • docs: a polling timeout bounds the retries, not a single call of the probe
  • test: a failed comparison held to a diff that explains its verdict, and a value that holds itself to the tree it unfolds into
  • test: every row of a failure held to showing a difference or saying why it does not
  • test: every message held to capped values, by a walk of the package and by a run of each family of assertion