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_HANLDERSare only invoked if the type hint exactly matches the type hint theTypeCasterwas created for -
CUSTOM_HANDLERSare invoked in case of an exact match or a subclass. -
If no match is found in both of the aforementioned dictionaries, the
DefaultTypeCasteris 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 aTypeCasteris overriden (e.g. registering a caster forlist)PROTOCOL_HANDLING: ProtocolHandlingLevel = ProtocolHandlingLevel.ALLOW -> Whether to allow atyping.Protocolas type hints. (Can beWARNto raise a warning orDISALLOWto 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
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
|