Make python functions more secure and descriptive.
Project description
fnsecure
This package aims to make python functions more secure by making them more explicit. The package is separated into the following parts:
- conditions: defines conditions on the function parameters to ensure safe execution of the function.
- exceptions: provides exception handling for the functions.
Conditions
Defines conditions on the function parameters to ensure safe execution of the function. Let's us introduce the concept through the following example. Assume that we have the given function:
def factorial(n):
# Check if input is negative
if n < 0:
return None
# Calculate factorial iteratively
result = 1
for i in range(1, n + 1):
result *= i
return result
This function is simple therefore it is easy to see that we do not always get a result back. To make our preconditions more explicit, we can write the following:
from conditions import expects
@expects(lambda n: n >= 0, None)
def factorial2(n: int) -> int:
# Calculate factorial iteratively
result = 1
for i in range(1, n + 1):
result *= i
return result
Decorator @expects has two inputs: a validator function, which receives all inputs
that the original function receives and returns True or False depending on whether
or not the precondition holds; if not, it returns the second value. Type hints will be
automatically updated to reflect the changes made by the decorator.
Exceptions
Python exceptions are handled using try-catch logic. This approach works; however,
it is not without its problems. Many great packages aim to introduce error handling
similar to that found in Rust or Go to Python. However, because Python uses
try-catch, these solutions can introduce a lot of friction and/or require a certain
setup that might not be possible for every project.
In this section, we go over some examples and try to show how we can manage exceptions more effectively using this module. The performance cost of handling exceptions this way is minimal, as we keep the "happy path" as clean as possible.
Let's assume we have the following functions:
def foo():
"""Can raise FileNotFoundError exception."""
...
return _r
def bar():
"""Can raise ZeroDivisionError and RuntimeError exceptions."""
...
return _r
Unhandled Exceptions
In many cases, exceptions are not handled because we know that in the given context the function will either not raise the exception, or if it does, we want the program to stop executing anyway. We can express the fact that this function will not be handled using the following:
from fnsecure.exceptions import raises, unhandled
@raises("foo", [FileNotFoundError])
def foo():
...
@unhandled("foo")
def function_that_calls_foo():
foo()
...
Decorator @raises takes a unique string as the first argument; by convention, this
should be the name of the function or class_name.function_name in case the function
name is not unique. The second parameter is a list of exception types that the function
can raise.
Decorator @unhandled takes the same unique string associated with the function to be
handled. When an exception is raised by function foo, the original exception will be
raised. Additionally, another exception signals that the function shouldn't have thrown
an exception in the given context.
Just like type hinting, this makes our function declarations more expressive and helps
signal intent. @raises makes it clear what exception to expect, while @unhandled
signals that the given function does not have to be handled in the current context.
Handled Exceptions
In other cases, exceptions need to be handled, but the same function might need to be handled differently depending on the context. For this reason, this module provides multiple exception handler functions to cover most cases. In this section, we go over them one by one, providing an example for each.
Decorator @handle takes the same unique string associated with the function to be
handled and a list of exception handlers.
Function: Raise
Functions handled with Raise will simply raise the exceptions. This handler should be
used when the exception should stop the execution of the program.
from fnsecure.exceptions import handle
from fnsecure.shared import ExceptionHandler
from fnsecure.exceptions.functions import Raise
@handle("foo", [ExceptionHandler(FileNotFoundError, Raise())])
def function_using_foo():
foo()
...
Function: RaiseWithMessage
Functions handled with RaiseWithMessage will raise the exception with a specific
message. This handler should be used when the exception should stop the execution of the
program and additional information needs to be provided.
from fnsecure.exceptions import handle
from fnsecure.shared import ExceptionHandler
from fnsecure.exceptions.functions import RaiseWithMessage
@handle("foo", [ExceptionHandler(FileNotFoundError, RaiseWithMessage("Message"))])
def function_using_foo():
foo()
...
Function: Continue
Functions handled with Continue will continue the program execution with the specified
value when an exception raised. This handler should be used when an exception should not
stop the execution of the program.
from fnsecure.exceptions import handle
from fnsecure.shared import ExceptionHandler
from fnsecure.exceptions.functions import Continue
@handle("foo", [ExceptionHandler(FileNotFoundError, Continue(None))])
def function_using_foo():
_r = foo() # _r will be None, when an exception is raised.
...
Function: Retry
Functions handled with Retry will retry the function with the same parameters up to
n times. It returns the value of the first successful function call. This should be
used for functions that can fail but successive function calls might be successful.
For example, connecting to a database.
from fnsecure.exceptions import handle
from fnsecure.shared import ExceptionHandler
from fnsecure.exceptions.functions import Retry
@handle("foo", [ExceptionHandler(FileNotFoundError, Retry(3))])
def function_using_foo():
_r = foo() # foo will be run three times after an exception is thrown
...
Function: RetryWithFunction
Functions handled with RetryWithFunction will replace the function with the provided
one and returns its result. This should be used for functions that when fail a new
function should be called. We can use this method to call the same function with
different parameters.
from fnsecure.exceptions import handle
from fnsecure.shared import ExceptionHandler
from fnsecure.exceptions.functions import RetryWithFunction
@handle(
"always_fail",
[
ExceptionHandler(
Exception, RetryWithFunction(lambda *args, **kwargs: "success", (), {})
)
],
)
def function_using_foo():
_r = foo() # _r will be "success" if an exception is thrown.
...
Handling All Exception
Decorator @handle will raise an exception, if not all exceptions are handled for a
given function. To cover all exceptions, we can use ExceptionHandler and
GroupExceptionHandler classes. These work similarly but GroupExceptionHandler takes
a list of exception types and maps them to the same handler function.
from fnsecure.shared import GroupExceptionHandler
@handle("bar", [GroupExceptionHandler([ZeroDivisionError, RuntimeError], Raise())])
def uses_bar():
bar()
Multithreading and Exception Handling
The groundwork has been laid for multithread support during exception handling; however,
this still needs some work. This is because by default, the exceptions module uses a
global context variable to check the registered functions and their handler functions.
These handler functions are updated and then cleared by the @handle decorator. Any
given function can only have one set of handler functions at a time, which are deleted
after they have been used. Therefore, if the handlers update the same set, that will
invalidate that set. We have to ensure that handlers cannot update the set of handler
functions. We can ensure that by creating a new context and using this context in
different threads. Here is an example:
from shared import Context
...
def create_threads(n: int):
threads = []
for i in range(n):
c = Context()
@raises("foo", [RuntimeError], c)
def foo():
...
@handle(..., c)
def function_calls_foo():
_r = foo()
thread = threading.Thread(target=function_calls_foo, args=(arg1, arg2))
threads.append(thread)
thread.start()
# Wait for all threads to complete
for thread in threads:
thread.join()
Later more convenient methods can be added as the need arises.
Project details
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 fnsecure-0.1.0.tar.gz.
File metadata
- Download URL: fnsecure-0.1.0.tar.gz
- Upload date:
- Size: 17.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: poetry/1.8.2 CPython/3.12.2 Linux/6.8.4-200.fc39.x86_64
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1ec909660f53c4bb09c20a6bdfebb2df78be90663c1aedb43127d038b7d77e0e
|
|
| MD5 |
d22e867d50fd822f3015d09cf1c3bab5
|
|
| BLAKE2b-256 |
0aef2c1964308368ec68213e4b5998ee98fda38594d935da9d16b7a23102df98
|
File details
Details for the file fnsecure-0.1.0-py3-none-any.whl.
File metadata
- Download URL: fnsecure-0.1.0-py3-none-any.whl
- Upload date:
- Size: 18.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: poetry/1.8.2 CPython/3.12.2 Linux/6.8.4-200.fc39.x86_64
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e8d2ff7a6d258cc255ea215b4a55462e001e68d836be583eb3384bcc3aa39bc6
|
|
| MD5 |
d0fcbaa898b59c621f4a17eb27bfe333
|
|
| BLAKE2b-256 |
d6f76f7e54656fe2d1333f185f666ac10ef90280917e4843bfff4b74db909b3f
|