Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .readthedocs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
version: 2

build:
os: ubuntu-24.04
tools:
python: "3.11"
jobs:
post_install:
- pip install uv
- UV_PROJECT_ENVIRONMENT=$READTHEDOCS_VIRTUALENV_PATH uv sync --link-mode=copy --frozen

mkdocs:
configuration: mkdocs.yml
fail_on_warning: true
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,68 @@
# compyre

## What is this?

`compyre` provides container unpacking and elementwise equality comparisons for arbitrary objects using their native functionality. It offers convenient defaults, while being fully configurable.

!["X all the Y" meme with "compyre all the things" as text](docs/images/meme.png "compyre meme")

## Why do I need it?

Have you ever found yourself in a situation where you needed to test a potentially nested container of values against a reference? [`pytest`](https://docs.pytest.org), the de facto standard test framework for Python, features awesome [failure reporting](https://docs.pytest.org/en/stable/example/reportingdemo.html) for builtin types such as dictionaries, lists, integers, strings, and so on.

But what about other common types that come with their own comparison logic, e.g. [`numpy.ndarray`](https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html#numpy.ndarray) and [`numpy.testing.assert_allclose`](https://numpy.org/doc/stable/reference/generated/numpy.testing.assert_allclose.html)? How do you compare a dictionary or worse a dataclass of these?

- Did you ever skip writing a proper test in such a situation and opted to write a simple, but incomplete one instead?
- If not, did you write the test as loop over the individual elements, and later spend more time debugging, because you have no way of knowing for which element the test failure happened?
- If not, is manually writing out all the assertions and maintaining them keeping you from working on the stuff that actually matters for your application or library?

If you have answered "yes" for any of the questions above, `compyre` was made for you.

## How do I get started?

Most basic cases can be covered by `compyre.equal` or `compyre.assert_equal`. The former provides a boolean check, while the latter raises an `AssertionError` with information what elements mismatch and why.

```python
import dataclasses

import numpy as np

import compyre

@dataclasses.dataclass
class MyObject:
id: str
data: list[np.ndarray]

expected = MyObject(
id="foo",
data=[np.array([1, 2]), np.array([3, 4])],
)

actual = MyObject(
id="bar",
data=[np.array([1, 2]), np.array([3, 5])],
)

compyre.assert_equal(actual, expected)
```

```
AssertionError: comparison resulted in 2 error(s):

id
AssertionError: 'bar' != 'foo'
data.1
AssertionError:
Not equal to tolerance rtol=1e-07, atol=0

Mismatched elements: 1 / 2 (50%)
Max absolute difference among violations: 1
Max relative difference among violations: 0.25
ACTUAL: array([3, 5])
DESIRED: array([3, 4])
```

## How do I learn more?

Please have a look at the [documentation](https://compyre.readthedocs.io/en/stable/).
11 changes: 11 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# API reference

::: compyre

::: compyre.api

::: compyre.builtin.unpack_fns

::: compyre.builtin.equal_fns

::: compyre.alias
54 changes: 54 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Getting started

## Installation

You can install `compyre` from [PyPI](https://pypi.org/project/compyre/) with your favorite tool, e.g.

```shell
pip install compyre
```

## Quick start

Most basic cases can be covered by [compyre.is_equal][] or [compyre.assert_equal][]. The former provides a boolean check, while the latter raises an `AssertionError` with information what elements mismatch and why.

```python
import dataclasses

import numpy as np

import compyre

@dataclasses.dataclass
class MyObject:
id: str
data: list[np.ndarray]

expected = MyObject(
id="foo",
data=[np.array([1, 2]), np.array([3, 4])],
)

actual = MyObject(
id="bar",
data=[np.array([1, 2]), np.array([3, 5])],
)

compyre.assert_equal(actual, expected)
```

```
AssertionError: comparison resulted in 2 error(s):

id
AssertionError: 'bar' != 'foo'
data.1
AssertionError:
Not equal to tolerance rtol=1e-07, atol=0

Mismatched elements: 1 / 2 (50%)
Max absolute difference among violations: 1
Max relative difference among violations: 0.25
ACTUAL: array([3, 5])
DESIRED: array([3, 4])
```
Binary file added docs/images/meme.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 19 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# compyre

## What is this?

`compyre` provides container unpacking and elementwise equality comparisons for arbitrary objects using their native functionality. It offers convenient defaults, while being fully configurable.

!["X all the Y" meme with "compyre all the things" as text](images/meme.png "compyre meme")

## Why do I need it?

Have you ever found yourself in a situation where you needed to test a potentially nested container of values against a reference? [`pytest`](https://docs.pytest.org), the de facto standard test framework for Python, features awesome [failure reporting](https://docs.pytest.org/en/stable/example/reportingdemo.html) for builtin types such as dictionaries, lists, integers, strings, and so on.

But what about other common types that come with their own comparison logic, e.g. [`numpy.ndarray`](https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html#numpy.ndarray) and [`numpy.testing.assert_allclose`](https://numpy.org/doc/stable/reference/generated/numpy.testing.assert_allclose.html)? How do you compare a dictionary or worse a dataclass of these?

- Did you ever skip writing a proper test in such a situation and opted to write a simple, but incomplete one instead?
- If not, did you write the test as loop over the individual elements, and later spend more time debugging, because you have no way of knowing for which element the test failure happened?
- If not, is manually writing out all the assertions and maintaining them keeping you from working on the stuff that actually matters for your application or library?

If you have answered "yes" for any of the questions above, `compyre` was made for you.
192 changes: 192 additions & 0 deletions docs/tutorials/aliases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
# Aliases

This tutorial will teach you what the `aliases` parameter of functions like [compyre.assert_equal][] is and how it is
used.

## Concept

Suppose you want to compare a [float][] as well as [numpy.ndarray][]

```python
import numpy as np

expected = {
"number": 1.0,
"array": np.array([2.0, 3.0])
}

actual = {
"number": 0.9,
"array": np.array([2.1, 2.8])
}
```

As you can see the values are not actually equal, but only close to each other

```python
from compyre import assert_equal

try:
assert_equal(actual, expected)
except AssertionError as e:
print(e)
```

```
comparison resulted in 2 error(s):

number
AssertionError: Numbers 0.9 and 1.0 are not close!

Absolute difference: 0.09999999999999998
Relative difference: 0.09999999999999998 (up to 1e-09 allowed)
array
AssertionError:
Not equal to tolerance rtol=1e-07, atol=0

Mismatched elements: 2 / 2 (100%)
Max absolute difference among violations: 0.2
Max relative difference among violations: 0.06666667
ACTUAL: array([2.1, 2.8])
DESIRED: array([2., 3.])
```

As floating point values rarely should be compared for bitwise equality, we need to set appropriate tolerances. Let's
assume for our use case that an absolute tolerance of `0.5` is fine.

The [float][] value is compared by [compyre.builtin.equal_fns.builtins_number][], which internally uses
[math.isclose][]. The [numpy.ndarray][] is compared by [compyre.builtin.equal_fns.numpy_ndarray][], which internally
uses [numpy.testing.assert_allclose][]. Unfortunately, both inner function don't agree on the parameter name for the
absolute tolerance: [math.isclose][] uses `abs_tol`, while [numpy.testing.assert_allclose][] uses `atol`. Meaning, we
can get our check to pass with

```python
assert_equal(actual, expected, abs_tol=0.5, atol=0.5)
```

Since this can be confusing and hard to maintain, `compyre` supports the concept of aliases. An alias can be attached to
each parameter of unpacking or equality check function, e.g. [compyre.builtin.equal_fns.builtins_number][] or
[compyre.builtin.equal_fns.numpy_ndarray][], and can be passed to the outer function, e.g. [compyre.assert_equal][].

```python
from compyre import alias

assert_equal(actual, expected, aliases={alias.ABSOLUTE_TOLERANCE: 0.5})
```

Explicitly passing a keyword argument takes priority of aliases. For example, to compare the [float][] without an
absolute tolerance, but keep it for all other types, we can explicitly pass the `abs_tol` keyword argument alongside the
alias:

```python
try:
assert_equal(
actual, expected, aliases={alias.ABSOLUTE_TOLERANCE: 0.5}, abs_tol=0
)
except AssertionError as e:
print(e)
```

```
comparison resulted in 1 error(s):

number
AssertionError: Numbers 0.9 and 1.0 are not close!

Absolute difference: 0.09999999999999998
Relative difference: 0.09999999999999998 (up to 1e-09 allowed)
```

## Built-in aliases

`compyre` currently supports the following aliases:

- [compyre.alias.RELATIVE_TOLERANCE][]
- [compyre.alias.ABSOLUTE_TOLERANCE][]
- [compyre.alias.NAN_EQUALITY][]

All functions from [compyre.builtin.unpack_fns][] and [compyre.builtin.equal_fns][] respect them where applicable.

## Custom aliases

Depending on your use case, you might want to introduce custom aliases. Suppose you have a concept "Foo" that is
applicable to multiple equality check functions. You start by creating an alias for that.

```python
from compyre import alias

FOO = alias.Alias("foo")
```

!!! tip

Passing a name to the alias, e.g. `"foo"` above, is optional, but encouraged to ease debugging.

Suppose further that the equality check functions while being able to handle the concept "Foo", they take the value
under different parameters, e.g. `bar` and `baz`. To apply the alias use it as part of a [typing.Annotated][]
annotation.

```python
from typing import Annotated

import compyre.api

def bool_equal_fn(p: compyre.api.Pair, /, *, bar: Annotated[str, FOO]) -> compyre.api.EqualFnResult:
if not (isinstance(p.actual, bool) and isinstance(p.expected, bool)):
return None

print(f"bool_equal_fn got {bar=}")

return p.actual is p.expected

def int_equal_fn(p: compyre.api.Pair, /, *, baz: Annotated[str, FOO]) -> compyre.api.EqualFnResult:
if not (isinstance(p.actual, int) and isinstance(p.expected, int)):
return None

print(f"int_equal_fn got {baz=}")

return p.actual == p.expected
```

Let's build a custom `assert_equal` function that uses our equality check functions and also is able to unpack
dictionaries, so we can pass multiple values at the same time.

```python
import functools

import compyre.builtin.unpack_fns

assert_equal = functools.partial(
compyre.api.assert_equal,
unpack_fns=[compyre.builtin.unpack_fns.collections_mapping],
equal_fns=[
bool_equal_fn,
int_equal_fn,
],
)
```

Calling it while passing a value for the `FOO` alias, we can observe that it is properly passed down as `bar` for
`bool_equal_fn` and as `baz` for `int_equal_fn`.

```python
value = {"bool": False, "int": 1}

assert_equal(value, value, aliases={FOO: "foo"})
```

```
bool_equal_fn got bar='foo'
int_equal_fn got baz='foo'
```

Passing a value for `bar` or `baz` directly takes priority over the alias:

```python
assert_equal(value, value, aliases={FOO: "foo"}, baz="baz")
```

```
bool_equal_fn got bar='foo'
int_equal_fn got baz='baz'
```
Loading