Repository navigation
2.30.0
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 apartBefore
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 emptyBefore
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 subjectBefore
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_openapiheld 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
oraclefor 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,hostnameanddurationchecks compared with the packages jsonschema asks, over 93 000 texts one or two parts away from a valid one - test: the
uricheck held to appendix A of RFC 3986 written out production by production, on every interpreter of the matrix - test: what
conforms_to_openapichecks of its own held to the official JSON-Schema-Test-Suite, 424 cases of seven formats and ofmultipleOf, laid into the repository with its licence - test: the
emailcheck held to section 4.1.2 of RFC 5321 written out production by production - test:
import assertpy2held 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 onceignore=orinclude=stands beside it - perf:
conforms_to_openapicopies 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
jsonextra installsidna, which judges thexn--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_structureread back from its text and held to what lies on the way to its mismatches - docs: a table of what
exact=Truereads and what it does not see, and how it differs from pydantic's per-callextra="forbid" - docs: a polling
timeoutbounds 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