""" Now that we have covered the basics of a function and variables, we will quickly cover decorators. Decorators are extremely useful and are used to wrap existing functions in another function, you can use this for many different things, a good example is permission checks. Key Concepts: **wrappers** are used to nest functions in another generalized function, this can be used for various things e.g (logging info) **args** we use the "*args* paramater to capture all positional based arguements, these come before keyword arguements. it sounds more complicated than it is, e.g myfunction(arg1, arg2, arg3) **kwargs** are arguements defined by a key rather than their position. e.g myfunction(keyword1="foo", keyword2="bar") **__name__** is just another magic/dunder method that returns the function's name. """ import time # Initial function takes in another function as a arguement def timed(function: any) -> any: # The wrapper, takes in the arguements and keyword arguements from the function def wrapper(*args: any, **kwargs: any) -> any: before = time.time() output = function(*args, **kwargs) after = time.time() print(f"{function.__name__} with output of {output} took {after-before}s to execute.") # Ensure the output of the function is passed back return output return wrapper # This is how we call a decorator in python. @timed # O(n^2) time complexity due to nested loops def exponential_function(n: int) -> int: output = 0 for i in range(n): for j in range(i): output += j return output # Test the decorated function # This will take some time to compute exponential_function(1000) # This will take noticeably more time to compute exponential_function(10000)