Skip to main content

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:

  1. conditions: defines conditions on the function parameters to ensure safe execution of the function.
  2. 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

fnsecure-0.1.1.tar.gz (17.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

fnsecure-0.1.1-py3-none-any.whl (18.4 kB view details)

Uploaded Python 3

File details

Details for the file fnsecure-0.1.1.tar.gz.

File metadata

  • Download URL: fnsecure-0.1.1.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

Hashes for fnsecure-0.1.1.tar.gz
Algorithm Hash digest
SHA256 c19a236b57aa6bf941bfb22d348b03e4132eb0b7d6572b5292f4ea3a4d3db2ea
MD5 ccc91feecb6e5aa23027f705c079a63e
BLAKE2b-256 e47fdab488c41ebc6e19803d0857fb657c0ff279491abca4bbd6c37bd8800db3

See more details on using hashes here.

File details

Details for the file fnsecure-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: fnsecure-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 18.4 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

Hashes for fnsecure-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 82c0c9d364ff3029cd87ecf204d7126a3124f539981236fe765fa494bda314f5
MD5 66d6ca5a33f8a74ad38a43f32f7ee24f
BLAKE2b-256 8f4de58f1c101ece423523b35a395117f1dab747f8cd6c5c0f33ac7928fa17e0

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page