Last active
May 9, 2022 03:48
-
-
Save SamuelHill/da106a0b2c4554c9155c60d35b0cb056 to your computer and use it in GitHub Desktop.
A few general purpose decorators; validate argument types based on the annotation, timer for timing function execution time, and a timer_average for running the decorated function a specified number of times
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python | |
# -*- coding: utf-8 -*- | |
# @Filename: decorators.py | |
# @Author: Samuel Hill | |
# @Email: whatinthesamhill@protonmail.com | |
# @Last update: 2022-05-07 21:02:20 | |
# @Updated by: Samuel Hill | |
# The 3-Clause BSD License (BSD-3-Clause) | |
# Copyright (c) 2022 Samuel Hill | |
"""A few general purpose decorators""" | |
from inspect import signature | |
from time import time | |
from time import sleep # only used for testing the timer_average decorator | |
from typing import Any | |
def timer(function_to_time): | |
"""Time the duration of the decorated function, printing the duration and | |
returning whatever was returned by the decorated function | |
(Callable argument function_to_time) | |
Returns: | |
Any: passes through the return value of the decorated function | |
""" | |
def wrapper(*args, **kwargs): | |
start = time() | |
saved_output = function_to_time(*args, **kwargs) | |
end = time() | |
print(f'\n{function_to_time.__name__} took {end - start}' | |
' seconds to run') | |
return saved_output | |
return wrapper | |
def timer_average(num_iterations: int): | |
"""Time the duration of the decorated function for the number of iterations | |
specified, and average the run time of all iterations. Prints the | |
average duration and returns whatever was returned by the decorated | |
function. Each iteration assigns function output to a variable for | |
timing consistency - ideally each run does the same operations. | |
Returns: | |
Any: passes through the return value of the decorated function | |
""" | |
def decorator(function_to_time): | |
def wrapper(*args, **kwargs): | |
times = [] | |
for _ in range(num_iterations): | |
start = time() | |
saved_output = function_to_time(*args, **kwargs) | |
end = time() | |
times.append(end - start) | |
avg_time = sum(times) / len(times) | |
# might be a better way to get the average than sum/len, could also | |
# get standard deviation or other metrics for better reporting | |
print(f'\n{function_to_time.__name__} took {avg_time} seconds to ' | |
f'run on average over {num_iterations} runs') | |
return saved_output | |
return wrapper | |
return decorator | |
class MissingAnnotationError(Exception): | |
"""raised when an argument doesn't have a type annotation""" | |
def validate(*variable_names: str) -> Any: | |
"""Simple type checking based on annotations. To use, pass in each variable | |
name you want to type check as a string to the decorator like so: | |
`@validate('variable_name', 'other_variable_name') | |
def test_function(variable_name: int, other_variable_name: str): | |
...` | |
Also checks that all passed in variable names are strings, that they | |
exist in the parameters of the decorated function, and that each | |
associated param has a valid type annotation. Can't handle | |
Tuple/Optional [...]'d types because parameterized types cannot be used | |
with isinstance(). I.e., this can only enforce built in data types | |
(str, int, float, list, dict, etc) or user defined classes. | |
Args: | |
*variable_names (str): list of all the variable names to check | |
Returns: | |
Any: passes through the return value of the decorated function | |
Raises: | |
TypeError: if any of the variable names given are not of type str | |
# NOTE: the following are raised from within the wrapper function... | |
ValueError: if a given variable is not found in the function arguments | |
Exception: if a given variable does not have a type annotation | |
TypeError: if a given variable is not of the annotated type | |
""" | |
for name in variable_names: # 2n instead of n by having this separated | |
if not isinstance(name, str): | |
raise TypeError(f'variable name {str(name)} must be of type str') | |
# code won't load if you aren't using strings, sanity check for if | |
# the call to validate is wrong or the type checking it is meant | |
# to do has raised an exception | |
def decorator(function_to_validate): | |
def wrapper(*args, **kwargs): | |
function_name = function_to_validate.__name__ | |
function_signature = signature(function_to_validate) | |
# bind fails if the args don't match up, but that is an issue with | |
# the function call and should be picked up by a linter, or by the | |
# appropriate Exceptions that are raised when called or bound here | |
arguments = function_signature.bind(*args, **kwargs).arguments | |
# I.e., if bind fails it's not a type checking issue | |
for variable in variable_names: | |
# TODO: have option to skip variables not in the params so that | |
# a list of args to check over all functions in a file can be | |
# passed in and filtered through | |
if variable not in function_signature.parameters: | |
raise ValueError(f'{variable} not in {function_name} args') | |
argument = function_signature.parameters[variable] | |
data_type = argument.annotation | |
# MissingAnnotationError is sort of a package deal for validate | |
if data_type is argument.empty: # same as _empty from inspect | |
raise MissingAnnotationError(f'{function_name} must have a' | |
' type annotation for ' | |
f'{variable} to validate') | |
# Only using isinstance for type checking, | |
# TODO: add an optional type_checking function to be passed in | |
# and used for more complex non-builtin data types. | |
# This begins to become more like a dumbed down mypy if we can | |
# handle parameterized types dynamically | |
if not isinstance(arguments[argument.name], data_type): | |
type_name = data_type.__name__ | |
raise TypeError(f'{variable} must be of type {type_name}') | |
return function_to_validate(*args, **kwargs) | |
return wrapper | |
return decorator | |
@validate('testing', 'number') | |
def _test_validate(testing: str, number: int): | |
print(testing, number) | |
# NOTE: this is an incorrect usage, testing should have a type annotation, | |
# meant to showcase the MissingAnnotationError exception raised with an | |
# un-annotated argument. | |
@validate('testing') | |
def _valid_annotate(testing): # pylint: disable=unused-argument | |
pass | |
# NOTE: this is an incorrect usage, 'testing' should be 'test', meant to | |
# showcase the ValueError exception raised with an argument name mismatch | |
@validate('testing') | |
def _valid_value(test: str): # pylint: disable=unused-argument | |
pass | |
# NOTE: this is an incorrect usage, 1 should be 'test' (or a variable with the | |
# value 'test' - e.g. temp = 'test'), meant to showcase the TypeError exception | |
# raised with a non-string argument | |
# @validate(1) | |
# def _valid_type(test: str): # pylint: disable=unused-argument | |
# pass | |
# Can't run the file with an issue like this, as the TypeError is generated | |
# on the initial loading of this function before getting into the decorator | |
# or wrapper functions | |
@timer | |
def _timed_test_suite(): | |
# DANGEROUS exec, but I wanted to eliminate the obnoxious try pattern | |
def try_exec(exec_string: str, error_condition: Exception = TypeError): | |
try: | |
print(f'\n{exec_string}') | |
exec(exec_string) # pylint: disable=exec-used | |
except error_condition as error_text: | |
print(f'{error_condition.__name__}: {error_text}') | |
print('Testing validate decorator error conditions:') | |
try_exec("_test_validate('test', 1)") | |
try_exec("_test_validate('test', '1')") | |
try_exec("_test_validate(7, 1)") | |
try_exec("_valid_annotate('test')", MissingAnnotationError) | |
try_exec("_valid_value('test')", ValueError) | |
# try_exec("_valid_type('test')") | |
@timer_average(30) | |
def _sleep_average(): | |
sleep(0.89) | |
if __name__ == '__main__': | |
_timed_test_suite() | |
print('\nTesting timer_average decorator with sleep, should take ~ 30sec') | |
_sleep_average() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment