Skip to content

Commit

Permalink
Add initial tests for polycheck
Browse files Browse the repository at this point in the history
  • Loading branch information
tarsil committed Oct 9, 2023
1 parent f7ecc35 commit 61ad306
Show file tree
Hide file tree
Showing 8 changed files with 201 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .pdbrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import os

alias kkk os.system('kill -9 %d' % os.getpid())
4 changes: 4 additions & 0 deletions polyforce/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
__version__ = "0.1.0"

from .decorator import polycheck

__all__ = ["polycheck"]
File renamed without changes.
74 changes: 74 additions & 0 deletions polyforce/decorator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import inspect
import typing
from functools import wraps
from typing import Any, _SpecialForm

from typing_extensions import get_args, get_origin


def polycheck(wrapped: Any) -> Any:
"""
Special decorator that enforces the
static typing.
Checks if all the fields are typed and if the functions have return
annotations.
"""
args_spec: inspect.FullArgSpec = inspect.getfullargspec(wrapped)

def check_signature(func: Any) -> Any:
"""
Validates the signature of a function and corresponding annotations
of the parameters.
"""
if inspect.isclass(func):
return func

signature: inspect.Signature = inspect.Signature.from_callable(func)
if signature.return_annotation == inspect.Signature.empty:
raise TypeError(
"A return value of a function should be type annotated. "
"If your function doesn't return a value or returns None, annotate it as returning 'NoReturn' or 'None' respectively."
)

for name, parameter in signature.parameters.items():
if parameter.annotation == inspect.Signature.empty:
raise TypeError(
f"'{name}' is not typed. If you are not sure, annotate with 'typing.Any'."
)

def check_types(*args: Any, **kwargs: Any) -> Any:
params = dict(zip(args_spec.args, args))
params.update(kwargs)

for name, value in params.items():
type_hint = args_spec.annotations.get(name, Any)

if isinstance(type_hint, _SpecialForm) or type_hint == Any:
continue

actual_type = get_origin(type_hint)
if isinstance(actual_type, typing._SpecialForm):
actual_type = get_args(value)
_type = actual_type or type_hint

if not isinstance(value, _type):
raise TypeError(
f"Expected type '{type_hint}' for attribute '{name}'"
f" but received type '{type(value)}') instead."
)

def decorate(func: Any) -> Any:
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
check_signature(func)
check_types(*args, **kwargs)
return func(*args, **kwargs)

return wrapper

if inspect.isclass(wrapped):
wrapped.__init__ = decorate(wrapped.__init__)
return wrapped

return decorate(wrapped)
Empty file added polyforce/metaclasses.py
Empty file.
Empty file added tests/__init__.py
Empty file.
50 changes: 50 additions & 0 deletions tests/test_dataclass.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from dataclasses import dataclass
from typing import Any, Optional, Union

import pytest

from polyforce import polycheck


@polycheck
@dataclass(frozen=True)
class User:
union_values: Union[int, str, float]
value: Any
name: str = ""
int_value: int = 1
_not: Optional[bool] = None


def test_polycheck():
user = User(union_values=2.0, value="A test")
assert isinstance(user.union_values, float)
assert isinstance(user.value, str)
assert isinstance(user.name, str)
assert isinstance(user.int_value, int)
assert user._not is None


def test_enforce_other_types():
user = User(union_values="user", value=["a", "list"], _not=True)
assert isinstance(user.union_values, str)
assert isinstance(user.value, list)
assert len(user.value) == 2
assert isinstance(user.name, str)
assert isinstance(user.int_value, int)
assert user._not is True


def test_dict_and_not_str_raise_error():
with pytest.raises(TypeError):
User(union_values={"a": 1})


def test_dict_and_not_str_raise_error_name():
with pytest.raises(TypeError):
User(name={"a": 1})


def test_str_and_not_int_raise_error():
with pytest.raises(TypeError):
User(int_value="a")
70 changes: 70 additions & 0 deletions tests/test_function.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from typing import Any, Optional, Union

import pytest

from polyforce import polycheck


@polycheck
def my_function(
union_values: Union[int, str, float],
value: Any,
name: str = "",
int_value: int = 1,
_not: Optional[bool] = None,
no_hint: Any = None,
) -> Any:
...


def test_polycheck():
my_function(union_values=2.0, value="A test")


def test_polycheck_all():
my_function(union_values=2.0, value=["a", "list"], name="function", int_value=2, _not=True)


def test_missing_return_annotation():
with pytest.raises(TypeError) as raised:

@polycheck
def test_func(name=None):
...

test_func()

assert (
str(raised.value)
== "A return value of a function should be type annotated. If your function doesn't return a value or returns None, annotate it as returning 'NoReturn' or 'None' respectively."
)


def test_missing_typing_annotation():
with pytest.raises(TypeError) as raised:

@polycheck
def test_func(name=None) -> None:
...

test_func()

assert (
str(raised.value)
== "'name' is not typed. If you are not sure, annotate with 'typing.Any'."
)


# def test_dict_and_not_str_raise_error():
# with pytest.raises(TypeError):
# User(union_values={"a": 1})


# def test_dict_and_not_str_raise_error_name():
# with pytest.raises(TypeError):
# User(name={"a": 1})


# def test_str_and_not_int_raise_error():
# with pytest.raises(TypeError):
# User(int_value="a")

0 comments on commit 61ad306

Please sign in to comment.