Package to leverage standard type hints in function and method signatures.
Project description
FancySignatures
This package provides an extensive, easy to use API to validate input arguments to functions and methods. It uses standard Python type hints to validate and/or cast to a given type. It also provides a way to perform additional validation on one or more parameters. Also convenience tools are provided for easily parsing data.
Basic usage
The most basic use case is to validate provided function arguments against their type hints.
from fancy_signatures import validate
@validate
def some_func(a: int, b: int) -> int:
return a + b
some_func(1, 2) # returns 3
some_func("1", 2) # returns 3
some_func("a", 2) # raises ValidationError
Use the lazy
parameter to control whether exceptions are raised or collected in an ExceptionGroup
and raised after all validations are done
from fancy_signatures import validate
@validate(lazy=True)
def some_func(a: int, b: int) -> int:
return a + b
some_func("a", "b") # raises ExceptionGroup with both TypeCastErrors
By default, FancySignatures
will attempt to typecast if type validation fails. To turn this behavior off, use the type_strict
parameter.
from fancy_signatures import validate
@validate(type_strict=True)
def some_func(a: int, b: int) -> int:
return a + b
some_func("1", 2) # raises ValidationError
Argument validators
You can perform other validations on arguments using the argument
function.
from fancy_signatures import validate, argument
from fancy_signatures.validation import GE
@validate
def some_func(a: int = argument(validators=[GE(0)])) -> int:
return a
some_func(-1)
# Raises: fancy_signatures.exceptions.ValidationError: Parameter 'a' is invalid. Value should be greater than or equal to 0.
Custom Validators
FancySignatures
provides a number of built-in validators, but you can also create your own.
Just inherit from the provided Validator
base class and implement the validate
method.
from fancy_signatures import validate, argument
from fancy_signatures import Validator
from fancy_signatures.exceptions import ValidatorFailed
class DivisibleByTwo(Validator[int | float]):
def validate(self, obj: int | float) -> int | float:
if obj % 2 != 0:
raise ValidatorFailed("Should be divisible by 2")
return obj
@validate
def custom_validator_func(a: int = argument(validators=[DivisibleByTwo()])) -> int:
return a
# fancy_signatures.exceptions.ValidationError: Parameter 'a' is invalid. Should be divisible by 2.
Validators should raise ValidatorFailed
if the validation fails, these exceptions will be caught by FancySignatures
.
If the validation is successfull, the original input value (obj
) should be returned.
Validating optional arguments
For validating optional arguments (Any | None
or typing.Optional[Any]
) validators are provided for you.
These validators will be skipped (i.e. they return None
) if it None
is passed as an argument.
from fancy_signatures import validate, argument
from fancy_signatures.validation import OptionalGE
@validate
def optional_arg(a: int | None = argument(validators=[OptionalGE(0)])) -> int | None:
return a
For creating your own optional validators, use the AllowOptionalMixin
.
from fancy_signatures import validate, argument
from fancy_signatures.validation import AllowOptionalMixin
class DivisibleByTwo(AllowOptionalMixin, Validator[int | float]):
def validate(self, obj: int | float) -> int | float:
if obj % 2 != 0:
raise ValidatorFailed("Should be divisible by 2")
return obj
@validate
def optional_arg(a: int | None = argument(validators=[DivisibleByTwo()])) -> int | None:
return a
Default values
With FancySignatures
you can provide defaults like you are used to. If you are using argument
it also takes a parameter default
that can be used to provide a value from the
built-in Default
class. If you so desire you can inherit from it and create you own Default
object by implementing the get
method. For most use cases though, the built in
DefaultValue
and DefaultFactory
should suffice.
from fancy_signatures import validate, argument
from fancy_signatures.default import DefaultValue, DefaultFactory
@validate
def default_value(a: bool = True, b: bool = argument(default=DefaultValue(True))) -> bool:
return a and b
@validate
def default_factory(a: list = argument(default=DefaultFactory(list))) -> list:
return a
print(default_value())
print(default_factory())
For convenience, some often used defaults are provided for you. For example Zero
(equivalent to DefaultValue(0)
) and EmptyList
(equivalent to DefaultFactory(list)
).
More on the argument
function
Next to functioning as a container for storing defaults and validators, the argument
function provides other functionality.
required
The required parameter controls whether an argument is mandatory. By default it is set to True
but setting it to False
means it can be ommitted completely.
Be aware FancySignatures
will pass non-required parameters that were not provided to the function as the __EmptyArg__
object. To deal with this in your functions
the is_empty
function is provided.
from fancy_signatures import validate, argument, is_empty
@validate
def empty_arguments(a: str = argument(required=False)) -> str | None:
if is_empty(a):
return
return a
print(empty_arguments())
print(empty_arguments("passed"))
If you don't use is empty you will see that a
is actually an instance of __EmptyArg__
.
from fancy_signatures import validate, argument
@validate
def empty_arguments(a: str = argument(required=False)) -> str | None:
return a
print(empty_arguments()) # prints: `FancySignaturesEmptyObject`
alias
The alias paramater allows you to provide an alias name for a parameter. It's useful when data is provided to you from a source you can't control (e.g. a http request). It allows you to use the function argument names you want while not being dependent on the naming of the data you receive.
from fancy_signatures import validate, argument
@validate
def func(input_value: str = argument(alias="inpval")) -> str:
return input_value
print(func(**{"inpval": "hello world"}))
Related validation
In addition to validating individual arguments FancySignatures
also provides a way to perform validations accross multiple arguments using the Related
object.
from fancy_signatures import validate
from fancy_signatures.validation.related import Related
def should_be_greater(a, b):
if a <= b:
raise ValidatorFailed("a should be greater than b")
@validate(related=[Related(should_be_greater, "a", "b")])
def related(a: int b: int) -> int:
return a + b
related(6, 5) # OK
related(4, 5) # Raises ValidationError
The Related
object expects the names of the arguments you want to validate as args
you can also map function argument names and validator argument names using kwargs
from fancy_signatures import validate
from fancy_signatures.validation.related import Related
def should_be_greater(a, b):
if a <= b:
raise ValidatorFailed("a should be greater than b")
@validate(related=[Related(should_be_greater, a="input_a", b="input_b")])
def related(input_a: int, input_b: int) -> int:
return input_a + input_b
related(6, 5) # OK
related(4, 5) # ValidationError: Parameters '['input_a', 'input_b']' are invalid. a should be greater than b.
Builtin related validators
FancySignatures
also provides a number of builtin related validators which can be found in the fancy_signatures.validation.related
module.
More on type validating and typecasting
To perform type vaidation and typecasting FancySignatures
uses the TypeCaster
interface. Internally, a number of TypeCaster
objects are implemented for all common type hints.
To create a TypeCaster
for a type hint, you can use the typecaster_factory
function. It takes a type hint and return the TypeCaster
for that hint.
Note Type hints that consist of other type hints, like GenericAlias
types and Union
are recursively checked. E.g. list[int]
will return a typecaster for list
and once the TypeCaster
is called, it will call typecaster_factory
again to validate the int
type.
Each TypeCaster
has a validate
and cast
method, to validate the type hint and cast to the given type hint respectively.
from fancy_signatures.typecasting import typecaster_factory
typecaster = typecaster_factory(list[int])
print(typecaster) # <fancy_signatures.typecasting.generic_alias.ListTupleSetTypeCaster object>
# In this case the typecaster will internally call the typecaster for int.
print(typecaster.validate([1, "2"])) # False
print(typecaster.validate([1, 2])) # False
print(typecaster.cast([1, "2"])) # [1, 2]
How a TypeCaster
and type are matched
FancySignatures
internally keeps track of which TypeCaster
belongs to which type hint. This is done in 2 dictionaries.
-
STRICT_CUSTOM_HANLDERS
are only invoked if the type hint exactly matches the type hint theTypeCaster
was created for -
CUSTOM_HANDLERS
are invoked in case of an exact match or a subclass. -
If no match is found in both of the aforementioned dictionaries, the
DefaultTypeCaster
is used. Which unpacks lists or dicts and tries to call the given type with the provided parameters.
Adding a TypeCaster
If you define your own class inheriting from typing.Generic
, FancySignatures
will be able to handle it.
from typing import Generic, TypeVar
from fancy_signatures import validate
T = TypeVar("T")
class MyClass(Generic[T]):
@property
def param(self) -> T:
return self._param
def __init__(self, param: T) -> None:
self._param = param
@validate
def custom_generic(a: MyClass[int]) -> int:
return a.param
print(custom_generic(MyClass(1))) # 1
# However no guarantees for the Generic subscription can be given.
print(custom_generic(MyClass("a"))) # "a"
To overcome the problem of FancySignatures
not knowing how to handle your custom Generic
classes. You can add your own typecaster by registering it with FancySignatures
from typing import Any, Generic, TypeVar, get_args
from fancy_signatures import validate, TypeCaster
from fancy_signatures.typecasting import register_typecaster, typecaster_factory
T = TypeVar("T")
class MyClass(Generic[T]):
@property
def param(self) -> T:
return self._param
def __init__(self, param: T) -> None:
self._param = param
class MyTypeCaster(TypeCaster[MyClass]):
def __init__(self, type_hint: Any) -> None:
super().__init__(type_hint)
self._subtype = get_args(type_hint)[0]
def validate(self, param_value: Any) -> bool:
if not isinstance(param_value, MyClass):
return False
if not isinstance(param_value.param, self._subtype):
return False
return True
def cast(self, param_value: Any) -> MyClass:
# If its not MyClass, create a new myclass with the param_value
if not isinstance(param_value, MyClass):
param_value = MyClass(param_value)
# Now use the typecaster_factory to create a caster for the subtype
param_value._param = typecaster_factory(self._subtype).cast(param_value._param)
return param_value
# strict=True, so an exact match is required. To also use this caster for subclasses use strict=False
register_typecaster(type_hints=[MyClass], handler=MyTypeCaster, strict=True)
@validate
def custom_generic(a: MyClass[int]) -> int:
return a.param
r = custom_generic(MyClass("1"))
print(r) # 1
print(type(r)) # <class 'int'>
custom_generic(MyClass([1, 3])) # ValidationError
Be aware registering a TypeCaster
that already exists (e.g. one for float
) is possible and might sometimes even be desirable to add specific functionality. FancySignatures
will throw a warning when you do this. A function unregister_typecaster
can be used to remove typecasters. This will not reinstate the previous caster, in order to do that, re-register the TypeCaster
Settings
FancySignatures
provides a settings module which you can use the customize (for now a limited amount) of behavior.
Currently there are 2 settigns:
WARN_ON_HANDLER_OVERRIDE
: bool = True -> Whether to raise a warning when aTypeCaster
is overriden (e.g. registering a caster forlist
)PROTOCOL_HANDLING
: ProtocolHandlingLevel = ProtocolHandlingLevel.ALLOW -> Whether to allow atyping.Protocol
as type hints. (Can beWARN
to raise a warning orDISALLOW
to raise anException
)
from fancy_signatures.settings import set, ProtocolHandlingLevel
# Change a settings
set("PROTOCOL_HANDLING", ProtocolHandlingLevel.WARN)
You can use settings.reset()
to reset all settings to their original values.
You can use settings.get_typecast_handlers()
to get a dictionairy of all registered TypeCasters
Classes and methods
Decorating methods and classes is possible. Be aware that for classes, internally the __init__
method will be wrapped.
So:
from fancy_signatures import validate, argument
from fancy_signatures.validation import GE
@validate
class MyClass:
def __init__(self, a: int = argument(validators=[GE(0)]), b: str = argument(alias="msg")) -> None:
self.a = a
self.b = b
Is equivalent to:
from fancy_signatures import validate, argument
from fancy_signatures.validation import GE
class MyClass:
@validate
def __init__(self, a: int = argument(validators=[GE(0)]), b: str = argument(alias="msg")) -> None:
self.a = a
self.b = b
You might find it cleare to directly decorate the __init__
method, up to you!
With dataclasses
from dataclasses import dataclass
from fancy_signatures import validate, argument
from fancy_signatures.validation import GE
@validate
@dataclass
class MyClass:
a: int = argument(validators=[GE(0)])
b: str = argument(alias="msg")
Exceptions
While internally FancySignatures
uses a number of different exceptions. When using @validate
only a ValidationError
(when lazy=False
) or ValidationErrorGroup
(when lazy=True
) will be raised. This means you only need to catch one exception based on the lazy
parameter. Additionally, ValidationErrorGroup
offers a to_dict()
mthod to convert the ExceptionGroup
to a dictionairy.
from fancy_signatures import validate, argument
from fancy_signatures.validation import GE
from fancy_signatures.exceptions import ValidationErrorGroup
@validate(lazy=True)
def my_func(
a: int = argument(validators=[GE(0)]),
b: int = argument(validators=[GE(0)]),
) -> int:
return a + b
try:
my_func(**{"a": -1, "b": "no_int"})
except ValidationErrorGroup as e:
print(e.to_dict())
"""
Returns:
{
'Parameter validation for my_func failed (2 sub-exceptions)': [
{
"Errors during validation of 'a' (1 sub-exception)": [
"Parameter 'a' is invalid. Value should be greater than or equal to 0."
]
},
"Parameter 'b' is invalid. Couldn't cast to the correct type. message: Couldn't cast to correct type: <class 'int'>. Couldn't cast to correct type: <class 'int'>. invalid literal for int() with base 10: 'no_int'."
]
}
"""
Full example
Below a working example of how to use the library is provided.
Put some errors in the input data (or remove the default for cost
for example) to see how FancySignatures
returns errors
from typing import Any
from dataclasses import dataclass
from fancy_signatures import validate, argument
from fancy_signatures.validation.related import switch_dependent_arguments
from fancy_signatures.exceptions import ValidationErrorGroup
from fancy_signatures.validation.validators import GE, LE
from fancy_signatures.default import DefaultValue
@validate(lazy=True, related=[switch_dependent_arguments("cost", switch_arg="in_stock")])
@dataclass
class Course:
name: str
cost: float | None = argument()
in_stock: bool = argument(default=DefaultValue(True))
@validate(lazy=True)
@dataclass
class Student:
name: str
age: int
courses: list[Course] = argument(alias="course_ids")
@validate(lazy=True)
@dataclass
class ClassRoom:
name: str
capacity: int = argument(validators=[GE(0), LE(50)], default=DefaultValue(10))
@validate(lazy=True)
def load_students(
budget: float = argument(validators=[GE(0)]),
students: list[Student] = argument(alias="student_info_"),
classrooms: list[ClassRoom] = argument(alias="rooms"),
) -> None:
print(budget)
print(students)
print(classrooms)
input_data = {
"budget": "125000",
"rooms": [
{
"name": "1A",
"capacity": "5"
},
{
"name": "1B",
"capacity": "3"
},
{
"name": "2A",
"capacity": "10"
},
],
"student_info_": [
{
"name": "Pete",
"age": 27,
"courses": [
{"name": "a", "cost": "1.2"}
]
},
{
"name": "Sarah",
"age": 25,
"course_ids": [
{"name": "b", "cost": "11.2"}
]
}
]
}
def main() -> None:
try:
load_students(**input_data)
except ValidationErrorGroup as e:
print(e.to_dict())
if __name__ == "__main__":
main()
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
File details
Details for the file FancySignatures-0.1.2.tar.gz
.
File metadata
- Download URL: FancySignatures-0.1.2.tar.gz
- Upload date:
- Size: 33.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/4.0.2 CPython/3.11.6
File hashes
Algorithm | Hash digest | |
---|---|---|
SHA256 | 5474cd905b6f95fa38586426457379e0e309880cd5038cd137d427bfdd2e47af |
|
MD5 | 2650c6881953d8944019318d1c6799aa |
|
BLAKE2b-256 | 4c2b7d4a5ee151ef41baf81f05f2937d7afcd6939712635c5bb01ec2a1053398 |
File details
Details for the file FancySignatures-0.1.2-py3-none-any.whl
.
File metadata
- Download URL: FancySignatures-0.1.2-py3-none-any.whl
- Upload date:
- Size: 29.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/4.0.2 CPython/3.11.6
File hashes
Algorithm | Hash digest | |
---|---|---|
SHA256 | ee7388a6014837f2cf8d045346721e231fbb19321fe941d8bd420240524f854c |
|
MD5 | 8b33b1e28e95d12115cb3c0fefcd7506 |
|
BLAKE2b-256 | ea2ff431fd9a9a96318c772dafe68fd8ba7138b039b0b4c4f13b0c0edcc04d2e |