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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,8 @@ print(4 * leibniz[1000])
│ ├── test_numeric_sequence.py # Pytest test suite for NumericSequence
│ ├── test_recurrence.py # Pytest test suite for Recurrence
│ ├── test_sequence.py # Pytest test suite for Sequence
│ └── test_series.py # Pytest test suite for Series
│ ├── test_series.py # Pytest test suite for Series
│ └── test_utils.py # Pytest test suite for utility functions
├── .gitignore
├── LICENSE
├── README.md
Expand Down
4 changes: 0 additions & 4 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,6 @@

- Identify opportunities to use decorators.

## Testing

- Add a unit test suite for `utils.py`.

## Documentation

- Add `CHANGELOG.md`.
Expand Down
67 changes: 34 additions & 33 deletions calculus/utils.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
"""Common utility functions for validating inputs across the project.

Functions:
validate_int: Validate that a value is an int and not a boolean.
validate_optional_int: Validate int or None, rejecting booleans.
validate_callable: Validate that a value is callable.
validate_int: Validate integer values, with configurable options.
validate_range: Validate range arguments.
"""

__all__ = [
"validate_callable",
"validate_int",
"validate_optional_int",
"validate_range",
]
__all__ = ["validate_callable", "validate_int", "validate_range"]


def validate_callable(value: object) -> None:
Expand All @@ -26,38 +22,43 @@ def validate_callable(value: object) -> None:
raise TypeError(f"'{type(value).__name__}' object is not callable")


def validate_int(value: int, name: str = "value") -> None:
"""Validate that a value is an integer and not a boolean.
def validate_int(
value: int | None,
name: str = "value",
allow_none: bool = False,
allow_bool: bool = False,
) -> None:
"""Validate that a value is an integer.

By default, only integers are accepted. The accepted values can be
extended to include None or boolean values using the corresponding
flags.

Args:
value (int): The value to validate.
value (int | None): The value to validate.
name (str): The variable name for error messages.
allow_none (bool): Whether None is accepted. Defaults to False.
allow_bool (bool): Whether boolean values are accepted. Defaults
to False.

Raises:
TypeError: If ``value`` is a bool or not an instance of int.
TypeError: If ``value`` is not of an accepted type.
"""
if not isinstance(value, int) or isinstance(value, bool):
if value is None:
if not allow_none:
raise TypeError(
f"'{name}' must be an integer, but got NoneType."
)
elif isinstance(value, bool):
if not allow_bool:
raise TypeError(
f"'{name}' must be an integer, but got bool."
)
elif not isinstance(value, int):
raise TypeError(
f"'{name}' must be an integer, but got {type(value).__name__}."
)


def validate_optional_int(value: int | None, name: str = "value") -> None:
"""Validate that a value is an integer or None, rejecting booleans.

If not None, it delegates to validate_int for strict checking.

Args:
value (int | None): The value to check.
name (str): The variable name for error messages.

Raises:
TypeError: If ``value`` is neither None nor a valid integer.
"""
if value is not None:
validate_int(value, name=name)


def validate_range(
start: int | None,
stop: int | None,
Expand All @@ -75,8 +76,8 @@ def validate_range(
integer or None.
ValueError: If ``step`` is zero.
"""
validate_optional_int(start, "start")
validate_optional_int(stop, "stop")
validate_optional_int(step, "step")
validate_int(start, "start", allow_none=True, allow_bool=True)
validate_int(stop, "stop", allow_none=True, allow_bool=True)
validate_int(step, "step", allow_none=True, allow_bool=True)
if step is not None and step == 0:
raise ValueError(f"step ({step}) cannot be zero")
4 changes: 2 additions & 2 deletions examples/rademacher_sequence.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

from calculus.sequence import INFINITY, Intfinity, Rule
from calculus.numeric_sequence import NumericSequence, Real
from calculus.utils import validate_callable, validate_optional_int
from calculus.utils import validate_callable, validate_int

#=======================================================================
# Rademacher Sequence
Expand Down Expand Up @@ -79,7 +79,7 @@ def __init__(
is not in ``sequence.FIRST_INDEX_OPTIONS``.
"""
if random_rule is None:
validate_optional_int(seed, "seed")
validate_int(seed, "seed", allow_none=True)
self._random_rule = self._Rule(random.Random(seed))
else:
validate_callable(random_rule)
Expand Down
81 changes: 81 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Tests for utils.

Run with:
pytest tests/test_utils.py -v
"""
import pytest

from calculus.utils import validate_callable, validate_int, validate_range

# -- CALLABLE VALIDATION

def test_validate_callable_accepts_callable() -> None:
validate_callable(lambda n: n)


def test_validate_callable_rejects_non_callable() -> None:
with pytest.raises(TypeError):
validate_callable("not callable")


# -- INTEGER VALIDATION

def test_validate_int_accepts_int() -> None:
validate_int(5)


def test_validate_int_rejects_none() -> None:
with pytest.raises(TypeError):
validate_int(None)


def test_validate_int_rejects_bool() -> None:
with pytest.raises(TypeError):
validate_int(True)


def test_validate_int_rejects_noninteger() -> None:
with pytest.raises(TypeError):
validate_int("5")


def test_validate_int_accepts_none_when_allowed() -> None:
validate_int(None, allow_none=True)


def test_validate_int_accepts_bool_when_allowed() -> None:
validate_int(True, allow_bool=True)


# -- RANGE VALIDATION

def test_validate_range_accepts_valid_range() -> None:
validate_range(1, 10, 2)


def test_validate_range_accepts_all_none() -> None:
validate_range(None, None, None)


def test_validate_range_accepts_bool() -> None:
validate_range(True, False, True)


def test_validate_range_rejects_noninteger_start() -> None:
with pytest.raises(TypeError):
validate_range("1", 10, 2)


def test_validate_range_rejects_noninteger_stop() -> None:
with pytest.raises(TypeError):
validate_range(1, "10", 2)


def test_validate_range_rejects_noninteger_step() -> None:
with pytest.raises(TypeError):
validate_range(1, 10, "2")


def test_validate_range_rejects_zero_step() -> None:
with pytest.raises(ValueError):
validate_range(1, 10, 0)