Skip to content

Instantly share code, notes, and snippets.

@SamuelHill
Last active May 9, 2022 03:48
Show Gist options
  • Save SamuelHill/da106a0b2c4554c9155c60d35b0cb056 to your computer and use it in GitHub Desktop.
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
#!/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