I have a small package that provides some API for playing around with the <An+B> CSS microsyntax. It is 100% type hinted and tested, but doesn't actually have any real/useful features and this question is not forabout the main code anyway.
I have a small package that provides some API for playing around with the <An+B> CSS microsyntax. It is 100% type hinted and tested, but doesn't actually have any real/useful features and this question is not for the main code anyway.
I have a small package that provides some API for playing around with the <An+B> CSS microsyntax. It is 100% type hinted and tested, but doesn't actually have any real/useful features and this question is not about the main code anyway.
Testing code for An+B microsyntax parsing library
I have a small package that provides some API for playing around with the <An+B> CSS microsyntax. It is 100% type hinted and tested, but doesn't actually have any real/useful features and this question is not for the main code anyway.
I was introduced to the Hypothesis testing library in a previous question and have taken a liking to it, so this package is a way for me to practice using it. Therefore, I'm asking for a review of my testing code. Only test files are presented here; other files can be found at the package's repository.
Here's the project structure (irrelevant files removed):
.
├── pyproject.toml
├── src
│ └── a_n_plus_b
│ ├── __init__.py
│ ├── _grammar.py
│ └── py.typed
├── tests
│ ├── __init__.py
│ ├── test_alternate_constructors.py
│ ├── test_indices.py
│ └── test_other_methods.py
└── tox.ini
tests/__init__.py
from collections.abc import Callable, Iterable
from typing import Any, ParamSpec, TypeVar
from _pytest.mark import ParameterSet
from hypothesis import example
from hypothesis.strategies import composite, DrawFn, integers, SearchStrategy
from a_n_plus_b import ANPlusB
_T = TypeVar('_T')
_P = ParamSpec('_P')
_Decorator = Callable[[Callable[_P, _T]], Callable[_P, _T]]
newlines = ['\r\n', '\r', '\n', '\f']
whitespace = ['\t', '\x20'] + newlines
blank = [''] + whitespace
def join(whatever: Iterable[Any]) -> str:
return ''.join(map(str, whatever))
# Originally from https://stackoverflow.com/a/70312417
def examples(
parameter_sets: Iterable[ParameterSet | tuple[Any, ...] | Any]
) -> _Decorator[_P, _T]:
parameter_sets = list(parameter_sets)
def inner(test_case: Callable[_P, _T]) -> Callable[_P, _T]:
for parameter_set in reversed(parameter_sets):
if isinstance(parameter_set, ParameterSet):
parameter_set = parameter_set.values
if not isinstance(parameter_set, tuple):
parameter_set = tuple([parameter_set])
test_case = example(*parameter_set)(test_case)
return test_case
return inner
@composite
def a_n_plus_b_instances(
draw: DrawFn,
steps: SearchStrategy[int] = integers(),
offsets: SearchStrategy[int] = integers()
) -> SearchStrategy[ANPlusB]:
return ANPlusB(draw(steps), draw(offsets))
tests/test_alternate_constructors.py
from collections.abc import Callable
from typing import Any, TypeVar
import pytest
from hypothesis import given
from hypothesis.strategies import (
composite, DrawFn, floats, integers, just, lists,
one_of, sampled_from, SearchStrategy, tuples
)
from a_n_plus_b import (
ANPlusB, ComplexWithNonIntegerPart,
EmptyInput, InputIsNotParsable
)
from . import examples, join, whitespace
_E = TypeVar('_E')
_T = TypeVar('_T')
def _make_complex(example: tuple[int | float, int | float]) -> complex:
return complex(*example)
def _starts_with_sign(example: tuple[str, Any]) -> bool:
text, _ = example
return text[0] in ('+', '-')
def _casing_scrambled(text: str) -> SearchStrategy[str]:
lowercase = text.lower()
uppercase = text.upper()
substrategies = (sampled_from(chars) for chars in zip(lowercase, uppercase))
return tuples(*substrategies).map(''.join)
@composite
def _variations(draw: DrawFn, text: str) -> str:
scrambled_text = draw(_casing_scrambled(text))
return draw(with_surrounding_whitespace(scrambled_text))
def _tupled_with(value: _T) -> Callable[[_E], tuple[_E, _T]]:
def tupled_with_given_value(example: _E) -> tuple[_E, _T]:
return example, value
return tupled_with_given_value
def _whitespace_sequences() -> SearchStrategy[str]:
return lists(
sampled_from(whitespace),
min_size = 1, max_size = 10
).map(''.join)
def whitespace_sequences_or_empty() -> SearchStrategy[str]:
return _whitespace_sequences() | just('')
def with_surrounding_whitespace(text: str) -> SearchStrategy[str]:
return tuples(
whitespace_sequences_or_empty(),
just(text),
whitespace_sequences_or_empty()
) \
.map(join)
def _non_integral_floats() -> SearchStrategy[float]:
return floats().filter(lambda example: not example.is_integer())
def _operators() -> SearchStrategy[str]:
return sampled_from(['+', '-'])
def _signs() -> SearchStrategy[str]:
return just('') | _operators()
def _digits() -> SearchStrategy[int]:
return integers(min_value = 0, max_value = 9)
@composite
def _integers_with_potentially_superfluous_sign(draw: DrawFn) -> str:
sign = draw(_signs())
digits = draw(lists(_digits(), min_size = 1, max_size = 10).map(join))
return draw(with_surrounding_whitespace(sign + digits))
@composite
def _a_values(draw: DrawFn) -> str:
sign = draw(_signs())
digits = draw(lists(_digits(), max_size = 10).map(join))
return sign + digits
def _b_values() -> SearchStrategy[str]:
return lists(_digits(), min_size = 1, max_size = 10).map(join)
class ParseANPlusBTestCases:
@staticmethod
@composite
def valid(draw: DrawFn) -> tuple[str, tuple[int, int]]:
a = draw(_a_values())
n = draw(sampled_from(['n', 'N']))
operator = draw(_signs())
if not a:
step = 0
elif a == '+':
step = 1
elif a == '-':
step = -1
else:
step = int(a)
if operator:
b = draw(_b_values())
offset = int(f'{operator}{b}')
else:
b = ''
offset = 0
operator = draw(with_surrounding_whitespace(operator))
text = draw(with_surrounding_whitespace(f'{a}{n}{operator}{b}'))
return text, (step, offset)
@staticmethod
@composite
def whitespace_after_a_sign(draw: DrawFn) -> str:
valid_cases = ParseANPlusBTestCases.valid().filter(_starts_with_sign)
valid_input = draw(valid_cases)[0]
a_sign, rest = valid_input[0], valid_input[1:]
invalid_whitespace = draw(_whitespace_sequences())
return f'{a_sign}{invalid_whitespace}{rest}'
@staticmethod
@composite
def missing_b(draw: DrawFn) -> str:
a = draw(_a_values())
n = draw(sampled_from(['n', 'N']))
operator = draw(_operators())
operator = draw(with_surrounding_whitespace(operator))
return draw(with_surrounding_whitespace(f'{a}{n}{operator}'))
@staticmethod
@composite
def missing_operator(draw: DrawFn) -> str:
a = draw(_a_values())
n = draw(sampled_from(['n', 'N']))
b = draw(_b_values())
b = draw(with_surrounding_whitespace(b))
return draw(with_surrounding_whitespace(f'{a}{n}{b}'))
@given(
one_of([
_variations('odd').map(_tupled_with((2, 1))),
_variations('even').map(_tupled_with((2, 0)))
])
)
def test_parse_odd_even(text_and_expected):
text, expected = text_and_expected
instance = ANPlusB.parse(text)
assert (instance.step, instance.offset) == expected
@given(_integers_with_potentially_superfluous_sign())
def test_parse_integer(text):
expected = int(text)
instance = ANPlusB.parse(text)
assert instance.step == 0
assert instance.offset == expected
@given(whitespace_sequences_or_empty())
def test_parse_empty(text):
with pytest.raises(EmptyInput):
ANPlusB.parse(text)
@given(ParseANPlusBTestCases.valid())
@examples([
(['+3n+2', (3, 2)]),
(['+4n+0', (4, 0)]),
(['+6n', (6, 0)]),
(['+5n-0', (5, 0)]),
(['+7n-1', (7, -1)]),
(['3n+2', (3, 2)]),
(['4n+0', (4, 0)]),
(['6n', (6, 0)]),
(['5n-0', (5, 0)]),
(['7n-1', (7, -1)]),
(['+0n+2', (0, 2)]),
(['+0n+0', (0, 0)]),
(['+0n', (0, 0)]),
(['+0n-0', (0, 0)]),
(['+0n-1', (0, -1)]),
(['0n+2', (0, 2)]),
(['0n+0', (0, 0)]),
(['0n', (0, 0)]),
(['0n-0', (0, 0)]),
(['0n-1', (0, -1)]),
(['-0n+2', (0, 2)]),
(['-0n+0', (0, 0)]),
(['-0n', (0, 0)]),
(['-0n-0', (0, 0)]),
(['-0n-1', (0, -1)]),
(['-3n+2', (-3, 2)]),
(['-4n+0', (-4, 0)]),
(['-6n', (-6, 0)]),
(['-5n-0', (-5, 0)]),
(['-7n-1', (-7, -1)]),
])
def test_parse_a_n_plus_b(text_and_expected):
text, expected = text_and_expected
instance = ANPlusB.parse(text)
assert (instance.step, instance.offset) == expected
@given(
one_of([
ParseANPlusBTestCases.whitespace_after_a_sign(),
ParseANPlusBTestCases.missing_b(),
ParseANPlusBTestCases.missing_operator()
])
)
def test_parse_invalid(text):
with pytest.raises(InputIsNotParsable):
ANPlusB.parse(text)
@given(
one_of([
tuples(integers(), integers()).map(_make_complex),
integers(),
integers().map(float)
])
)
def test_from_complex(value):
instance = ANPlusB.from_complex(value)
expected = (int(value.imag), int(value.real))
assert (instance.step, instance.offset) == expected
@given(
one_of([
tuples(_non_integral_floats(), integers()),
tuples(integers(), _non_integral_floats()),
tuples(_non_integral_floats(), _non_integral_floats())
]) \
.map(_make_complex)
)
def test_from_complex_invalid(value):
with pytest.raises(ComplexWithNonIntegerPart):
ANPlusB.from_complex(value)
tests/test_indices.py
from collections.abc import Iterable
from itertools import product
from typing import cast, Literal
import pytest
from _pytest.mark import ParameterSet
from hypothesis import assume, given
from hypothesis.strategies import (
booleans, composite, DrawFn, from_type, integers, just,
one_of, sampled_from, SearchStrategy, tuples
)
from a_n_plus_b import ANPlusB, InvalidNumberOfChildren, InvalidOrder
from . import a_n_plus_b_instances, examples
_Order = Literal['ascending', 'descending', 'default']
_TestCase = tuple[ANPlusB, tuple[int, bool, _Order], Iterable[int]]
def _ascending(values: Iterable[int]) -> Iterable[int]:
return sorted(values)
def _descending(indices: Iterable[int]) -> Iterable[int]:
return sorted(indices, reverse = True)
def _from_last(indices: Iterable[int], population: int) -> Iterable[int]:
return [population - index + 1 for index in indices]
def _sign(value: int, /) -> Literal['negative', 'positive', 'zero']:
return 'negative' if value < 0 else 'positive' if value > 0 else 'zero'
def _describe(test_case: _TestCase) -> str:
instance, (population, from_last, order), _ = test_case
step, offset = instance.step, instance.offset
descriptions = [
f'{_sign(step)} step',
f'{_sign(offset)} offset',
f'from last' if from_last else 'from first',
f'{order}'
]
return ', '.join(descriptions)
def _human_integers(
min_value: int = -(2 ** 16),
max_value: int = 2 ** 16,
) -> SearchStrategy[int]:
return integers(
min_value = max(-(2 ** 16), min_value),
max_value = min(2 ** 16, max_value)
)
def _orders() -> SearchStrategy[_Order]:
return cast(
SearchStrategy[_Order],
sampled_from(['ascending', 'descending', 'default'])
)
def _non_positive_step_and_offset_test_cases() -> SearchStrategy[_TestCase]:
return tuples(
a_n_plus_b_instances(
integers(max_value = 0),
integers(max_value = 0)
),
tuples(integers(min_value = 0), booleans(), _orders()),
just(list[int]())
)
@composite
def _zero_step_test_cases(draw: DrawFn) -> _TestCase:
argument_sets = tuples(integers(min_value = 0), booleans(), _orders())
instance = draw(a_n_plus_b_instances(just(0), integers()))
population, from_last, order = draw(argument_sets)
b = instance.offset
index = population - b + 1 if from_last else b
expected = [index] if 1 <= b <= population else list[int]()
return instance, (population, from_last, order), expected
@composite
def _non_positive_step_zero_offset_test_cases(draw: DrawFn) -> _TestCase:
argument_sets = tuples(integers(min_value = 0), booleans(), _orders())
instance = draw(a_n_plus_b_instances(integers(max_value = -1), just(0)))
arguments = draw(argument_sets)
return instance, arguments, list[int]()
def _test_case_group(
instance: ANPlusB,
population: int,
base_case_expected: Iterable[int]
) -> list[ParameterSet]:
test_cases = []
from_last_and_order_matrix = product(
[False, True],
['default', 'ascending', 'descending']
)
for from_last, order in from_last_and_order_matrix:
expected = base_case_expected
if from_last:
expected = _from_last(base_case_expected, population)
if order == 'ascending':
expected = _ascending(expected)
elif order == 'descending':
expected = _descending(expected)
test_case = (instance, (population, from_last, order), expected)
test_cases.append(
pytest.param(test_case, id = _describe(test_case))
)
return test_cases
@given(
one_of([
_non_positive_step_and_offset_test_cases(),
_zero_step_test_cases(),
_non_positive_step_zero_offset_test_cases()
])
)
@examples([
*_test_case_group(ANPlusB(3, 0), 100, list(range(3, 101, 3))),
*_test_case_group(ANPlusB(-2, 6), 10, [6, 4, 2]),
*_test_case_group(ANPlusB(-1, 4), 8, [4, 3, 2, 1]),
*_test_case_group(ANPlusB(-3, 8), 18, [8, 5, 2]),
*_test_case_group(ANPlusB(4, -5), 20, [3, 7, 11, 15, 19]),
*_test_case_group(ANPlusB(5, -2), 12, [3, 8]),
*_test_case_group(ANPlusB(2, 1), 15, [1, 3, 5, 7, 9, 11, 13, 15]),
*_test_case_group(ANPlusB(3, 1), 10, [1, 4, 7, 10]),
*_test_case_group(ANPlusB(1, 4), 11, [4, 5, 6, 7, 8, 9, 10, 11])
])
def test_indices(instance_arguments_expected: _TestCase):
instance, arguments, expected = instance_arguments_expected
population, from_last, order = arguments
indices = instance.indices(population, from_last = from_last, order = order)
assert list(indices) == expected
@given(
a_n_plus_b_instances(),
integers(), booleans(), from_type(str)
)
def test_indices_invalid_order(instance, population, from_last, order):
assume(order not in ('ascending', 'descending', 'default'))
with pytest.raises(InvalidOrder):
instance.indices(population, from_last = from_last, order = order)
@given(
a_n_plus_b_instances(),
integers(max_value = -1), booleans(), _orders()
)
def test_indices_invalid_number_of_children(
instance, population, from_last, order
):
with pytest.raises(InvalidNumberOfChildren):
instance.indices(population, from_last = from_last, order = order)
tests/test_other_methods.py
import pytest
from hypothesis import given, infer
from hypothesis.strategies import from_type, integers
from a_n_plus_b import ANPlusB, IncorrectUseOfConstructor
from . import a_n_plus_b_instances
_EqTestCase = tuple[ANPlusB, '_ANPlusBSubclass', bool]
class _ANPlusBSubclass(ANPlusB):
pass
@given(integers(), integers())
def test_construction(step, offset):
instance = ANPlusB(step, offset)
assert instance.step == step
assert instance.offset == offset
@given(integers())
def test_construction_single_argument(offset):
instance = ANPlusB(offset)
assert instance.step == 0
assert instance.offset == offset
@given(infer)
def test_construction_invalid(text: str):
with pytest.raises(IncorrectUseOfConstructor):
ANPlusB(text) # noqa
@pytest.mark.parametrize(('instance', 'expected'), [
(ANPlusB(0, -2), '-2'),
(ANPlusB(0, 0), '0'),
(ANPlusB(0, 2), '2'),
(ANPlusB(1, -3), 'n-3'),
(ANPlusB(1, 0), 'n'),
(ANPlusB(1, 3), 'n+3'),
(ANPlusB(-1, -4), '-n-4'),
(ANPlusB(-1, 0), '-n'),
(ANPlusB(-1, 4), '-n+4'),
(ANPlusB(3, 4), '3n+4'),
(ANPlusB(3, 0), '3n'),
(ANPlusB(3, -5), '3n-5'),
(ANPlusB(-4, 5), '-4n+5'),
(ANPlusB(-4, 0), '-4n'),
(ANPlusB(-4, -6), '-4n-6')
])
def test_str(instance, expected):
assert str(instance) == expected
assert repr(instance) == f'{ANPlusB.__name__}({expected})'
@given(a_n_plus_b_instances())
def test_values(instance):
a, b = instance.step, instance.offset
for index, value in zip(range(10), instance.values()):
assert value == a * index + b
@given(a_n_plus_b_instances(), integers(min_value = 0))
def test_values_contain_getitem(instance, index):
a, b = instance.step, instance.offset
value = instance.values()[index]
assert value == a * index + b
assert value in instance.values()
@given(
a_n_plus_b_instances(),
from_type(object).filter(lambda o: not isinstance(o, int))
)
def test_values_not_contain(instance, item):
assert item not in instance.values()
@given(a_n_plus_b_instances(), integers(max_value = -1))
def test_getitem_invalid(instance, index):
with pytest.raises(IndexError):
_ = instance.values()[index]
@given(a_n_plus_b_instances())
def test_eq(this):
a, b = this.step, this.offset
that = ANPlusB(a, b)
assert this == that
assert hash(this) == hash(that) == hash((a, b))
@pytest.mark.parametrize(('this', 'that', 'expected'), [
(ANPlusB(-2, -3), ANPlusB(-2, -3), True),
(ANPlusB(-1, 0), ANPlusB(-1, 0), True),
(ANPlusB(0, -5), ANPlusB(0, -5), True),
(_ANPlusBSubclass(-4, -3), _ANPlusBSubclass(-4, -3), True),
(_ANPlusBSubclass(-1, -2), _ANPlusBSubclass(-1, -2), True),
(_ANPlusBSubclass(0, 0), _ANPlusBSubclass(0, 0), True),
(ANPlusB(0, 0), _ANPlusBSubclass(0, 0), True),
(_ANPlusBSubclass(0, 0), ANPlusB(0, 0), True),
(ANPlusB(3, 4), ANPlusB(4, 3), False),
(_ANPlusBSubclass(3, 4), _ANPlusBSubclass(4, 3), False),
(ANPlusB(-2, -3), ANPlusB(-4, -5), False),
(_ANPlusBSubclass(-1, -2), _ANPlusBSubclass(-3, -4), False),
])
def test_eq_subclass(this, that, expected):
assert (this == that) is expected