Metadata-Version: 2.1
Name: redbreast
Version: 1.1.1
Summary: Robin's python utilities
Home-page: https://github.com/binnev/redbreast
Author: Robin Neville
Author-email: robin.m.neville@gmail.com
Project-URL: Bug Tracker, https://github.com/binnev/redbreast/issues
Classifier: Programming Language :: Python :: 3
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: django (>=3.2)
Requires-Dist: typing (>=3.7)
Requires-Dist: packaging (>=3.0.9)

# Robin's python utilities

<img src="https://github.com/binnev/redbreast/raw/main/logo.png"/>

### Installation

```
pip install redbreast
```

## QueryList

Do you want that sweet Django QuerySet filtering, but your objects aren't in a database, and you also don't want to
write a filter / list comprehension? The QueryList is the object for you!

### Usage

Let's do some setup. We want to investigate the following group of dogs:

```python
from dataclasses import dataclass
from redbreast.querylist import QueryList


@dataclass
class Dog:
    name: str
    owner: str
    number: float


fido = Dog(number=15.72, name="Fido", owner="Sam")
muttley = Dog(number=31.44, name="Muttley", owner="Robin")
biko = Dog(number=47.17, name="Biko", owner="Sam")
buster = Dog(number=71.19, name="Buster", owner="Robin")

dogs = QueryList([fido, muttley, biko, buster])
```

We can `filter` for strict equality:

```python
dogs.filter(name="Muttley").first()
# Dog(name='Muttley', owner='Robin', number=31.44)
```

Or we can do django-like filtering with double underscores in the query:

```python
dogs.filter(number__gt=30, number__lt=70)
# [
#     Dog(name='Muttley', owner='Robin', number=31.44), 
#     Dog(name='Biko', owner='Sam', number=47.17),
# ]
```

`exclude` works too:

```python
dogs.exclude(owner="Sam")
# [
#     Dog(name='Muttley', owner='Robin', number=31.44), 
#     Dog(name='Buster', owner='Robin', number=71.19),
# ]
```

The `get` method works like in Django -- it has to match exactly one object or it will raise an exception:

```python
# one object matches this query
dogs.get(name="Muttley")
# Dog(name='Muttley', owner='Robin', number=31.44)

# multiple objects match this query
dogs.get(owner="Robin")
# Traceback (most recent call last):
#   File ".../redbreast/querylist.py", line 34, in get
#     raise MultipleObjectsReturned
# django.core.exceptions.MultipleObjectsReturned

# no objects match this query
dogs.get(name="Penelope")
# Traceback (most recent call last):
#   File "/home/binnev/code/redbreast/redbreast/querylist.py", line 32, in get
#     raise ObjectDoesNotExist
# django.core.exceptions.ObjectDoesNotExist
```

Also, it's worth noting that QueryList can handle dictionaries (with `["key_lookup"]`) as well as objects (
with `.dot_lookup`). There can even be a mix of dictionaries and objects in the QueryList:

```python
things = QueryList(
    [
        {"name": "foo", "number": 69, "owner": "Jane"},  # dict
        Dog(name="bar", number=420, owner="Johnny"),  # object
    ]
)

print(things.get(owner="Jane"))
# {'name': 'foo', 'number': 69, 'owner': 'Jane'}

print(things.get(owner="Johnny"))
# Dog(name='bar', owner='Johnny', number=420)
```

### Adding query methods

If you want to extend the functionality of QueryList by adding more dunder query methods, you can use
the `register_operation` method:

```python
def longer_than(item, target_length: int) -> bool:
    return len(item) > target_length


QueryList.register_operation("longerthan", longer_than)
dogs = QueryList(
    [
        dict(name="foo"),
        dict(name="fooooooooooooooo"),
    ]
)
dogs.filter(name__longerthan=3).first()["name"]
# "fooooooooooooooo"
```

### Caveats

Django's QuerySet is "lazy" -- meaning that `filter`/`exclude` calls do not actually hit the database. Instead they
simply add to an SQL query that the QuerySet remembers. This query is only sent to the database when you call `all`
/`first`/`last`/`exists` on the QuerySet.

By contrast, the QueryList is _not_ lazy. It will execute every `filter`/`exclude` call on the spot (thus reducing the
number of items it contains):

```python
dogs = dogs.filter(owner="Robin")
print(dogs)
# [
#     Dog(name='Muttley', owner='Robin', number=31.44), 
#     Dog(name='Buster', owner='Robin', number=71.19),
# ]
```

## Parametrize

This is a thin wrapper around `pytest.mark.parametrize` that provides better oversight of tests with lots of parameters.

Consider the following (totally useless) test:

```python
@pytest.mark.parametrize(
    "a, b, c, d, e, f, g, h, i",
    [
        (True, 2, "foo", [], (), 69, 7, 8, ...),
        (False, 2, "bar", [], (), 420, 7, 8, ...),
        (None, 2, "baz", [], (), 666, 7, 8, ...),
        (True, 2, "baz", [], (), 9000, 7, 8, ...),
    ],
)
def test_parametrize(a, b, c, d, e, f, g, h, i):
    assert a in [True, False, None]
    assert b == 2
    assert isinstance(c, str)
    assert isinstance(d, list) and len(d) == 0
    assert isinstance(e, tuple) and len(e) == 0
    assert isinstance(f, int)
    assert g == 7
    assert h == 8
    assert i == Ellipsis
```

It was difficult to write because I couldn't quickly see which parameter name mapped to which value. Let's rewrite the
test using my parametrize wrapper:

```python
from redbreast.testing import parametrize, testparams


@parametrize(
    param := testparams("a", "b", "c", "d", "e", "f", "g", "h", "i"),
    [
        param(a=True, b=2, c="foo", d=[], e=(), f=69, g=7, h=8, i=...),
        param(a=False, b=2, c="bar", d=[], e=(), f=420, g=7, h=8, i=...),
        param(a=None, b=2, c="baz", d=[], e=(), f=666, g=7, h=8, i=...),
        param(a=True, b=2, c="baz", d=[], e=(), f=9000, g=7, h=8, i=...),
    ],
)
def test_parametrize(param):
    assert param.a in [True, False, None]
    assert param.b == 2
    assert isinstance(param.c, str)
    assert isinstance(param.d, list) and len(param.d) == 0
    assert isinstance(param.e, tuple) and len(param.e) == 0
    assert isinstance(param.f, int)
    assert param.g == 7
    assert param.h == 8
    assert param.i == Ellipsis
```

Much better. I can see at a glance that `g=7`, for example.

By invoking `param := testparams("a", "b", "c", ...)` we are creating a dataclass on the fly, which accepts
arguments `"a", "b", "c", ...`, and acts as our container for each test case. All arguments are optional, and default
to `None` if no value is supplied. Only keyword arguments are allowed, because the whole point is to make the test more
descriptive. Using positional args -- `param(1, 2, 3, ...)` -- is not allowed.

The dataclass has the default argument `description` to encourage you to describe each test case. The description is
also passed to pytest so that it shows up nicely in output. By default, pytest tries to generate a description based on
the items in the list:

```python
# test_testing.py::test_parametrize[param0] PASSED                         [ 25%]
# test_testing.py::test_parametrize[param1] PASSED                         [ 50%]
# test_testing.py::test_parametrize[param2] PASSED                         [ 75%]
# test_testing.py::test_parametrize[param3] PASSED                         [100%]
```

If we pass the following descriptions, they will show up in the output instead:

```python
@parametrize(
    param := testparams("a", "b", "c", "d", "e", "f", "g", "h", "i"),
    [
        param(description="1st test case", ...),
        param(description="2nd test case", ...),
        param(description="3rd test case", ...),
        param(description="4th test case", ...),
    ],
)
def test_parametrize(param):
    ...

# ============================= test session starts ==============================
# collecting ... collected 4 items
# 
# test_testing.py::test_parametrize[1st test case] PASSED                  [ 25%]
# test_testing.py::test_parametrize[2nd test case] PASSED                  [ 50%]
# test_testing.py::test_parametrize[3rd test case] PASSED                  [ 75%]
# test_testing.py::test_parametrize[4th test case] PASSED                  [100%]
# 
# ============================== 4 passed in 0.04s ===============================
```

Admittedly, our descriptions in this example aren't much more useful than pytest's ones, but you can go into as much
detail as you want. I sometimes write a small paragraph describing the motivation / context around a test case. 
