diff --git a/poetry.lock b/poetry.lock index 68df6df..044e16a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -316,4 +316,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "43238a4a9b3c3d7aa608a6cbd6a95dd47889b7edfeb392db46529c2389c9c120" +content-hash = "6a92a75c564698cb87dc8e31a9df0b685a262c532cc0637fd1f90a91a0106cfe" diff --git a/pyproject.toml b/pyproject.toml index 9dbec22..c771efd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ thread = "thread.__main__:app" [tool.poetry.dependencies] python = "^3.9" typer = {extras = ["all"], version = "^0.9.0"} +typing-extensions = "^4.9.0" [tool.poetry.group.dev.dependencies] diff --git a/src/thread/__init__.py b/src/thread/__init__.py index c59831c..560f576 100644 --- a/src/thread/__init__.py +++ b/src/thread/__init__.py @@ -1,10 +1,41 @@ +""" +## Thread Library +Documentation at https://thread.ngjx.org + + +--- + +Released under the GPG-3 License + +Copyright (c) thread.ngjx.org, All rights reserved +""" + +""" +This file contains the exports to +```py +import thread +``` +""" + + +# Export Core from .thread import ( Thread, - ParallelProcessing, + ParallelProcessing ) + from . import ( + _types as types, exceptions ) + +# Export decorators +from .decorators import ( + threaded +) + + +# Configuration from .utils import Settings diff --git a/src/thread/_types.py b/src/thread/_types.py new file mode 100644 index 0000000..9204c47 --- /dev/null +++ b/src/thread/_types.py @@ -0,0 +1,31 @@ +""" +## Types + +Documentation: https://thread.ngjx.org +""" + +from typing import Any, Literal, Callable, Union + + +# Descriptive Types +Data_In = Any +Data_Out = Any +Overflow_In = Any + + +# Variable Types +ThreadStatus = Literal[ + 'Idle', + 'Running', + 'Invoking hooks', + 'Completed', + + 'Errored', + 'Kill Scheduled', + 'Killed' +] + + +# Function types +HookFunction = Callable[[Data_Out], Union[Any, None]] +TargetFunction = Callable[..., Data_Out] diff --git a/src/thread/decorators.py b/src/thread/decorators.py new file mode 100644 index 0000000..4755d3c --- /dev/null +++ b/src/thread/decorators.py @@ -0,0 +1,135 @@ +""" +## Decorators + +Documentation: https://thread.ngjx.org +""" + +from functools import wraps +from .thread import Thread + +from ._types import Overflow_In, Data_Out, Data_In +from typing import Callable, Mapping, Sequence, Optional, Union, overload +from typing_extensions import ParamSpec, TypeVar + + +T = TypeVar('T') +P = ParamSpec('P') +TargetFunction = Callable[P, Data_Out] +NoParamReturn = Callable[P, Thread] +WithParamReturn = Callable[[TargetFunction], NoParamReturn] +FullParamReturn = Callable[P, Thread] +WrappedWithParamReturn = Callable[[TargetFunction], WithParamReturn] + + +@overload +def threaded(__function: TargetFunction) -> NoParamReturn: ... + +@overload +def threaded( + *, + args: Sequence[Data_In] = (), + kwargs: Mapping[str, Data_In] = {}, + ignore_errors: Sequence[type[Exception]] = (), + suppress_errors: bool = False, + **overflow_kwargs: Overflow_In +) -> WithParamReturn: ... + +@overload +def threaded( + __function: Callable[P, Data_Out], + *, + args: Sequence[Data_In] = (), + kwargs: Mapping[str, Data_In] = {}, + ignore_errors: Sequence[type[Exception]] = (), + suppress_errors: bool = False, + **overflow_kwargs: Overflow_In +) -> FullParamReturn: ... + + +def threaded( + __function: Optional[TargetFunction] = None, + *, + args: Sequence[Data_In] = (), + kwargs: Mapping[str, Data_In] = {}, + ignore_errors: Sequence[type[Exception]] = (), + suppress_errors: bool = False, + **overflow_kwargs: Overflow_In +) -> Union[NoParamReturn, WithParamReturn, FullParamReturn]: + """ + Decorate a function to run it in a thread + + Parameters + ---------- + :param __function: The function to run in a thread + :param args: Keyword-Only arguments to pass into `thread.Thread` + :param kwargs: Keyword-Only keyword arguments to pass into `thread.Thread` + :param ignore_errors: Keyword-Only arguments to pass into `thread.Thread` + :param suppress_errors: Keyword-Only arguments to pass into `thread.Thread` + :param **: Keyword-Only arguments to pass into `thread.Thread` + + Returns + ------- + :return decorator: + + Use Case + -------- + Now whenever `myfunction` is invoked, it will be executed in a thread and the `Thread` object will be returned + + >>> @thread.threaded + >>> def myfunction(*args, **kwargs): ... + + >>> myJob = myfunction(1, 2) + >>> type(myjob) + > Thread + + You can also pass keyword arguments to change the thread behaviour, it otherwise follows the defaults of `thread.Thread` + >>> @thread.threaded(daemon = True) + >>> def myfunction(): ... + + Args will be ordered infront of function-parsed args parsed into `thread.Thread.args` + >>> @thread.threaded(args = (1)) + >>> def myfunction(*args): + >>> print(args) + >>> + >>> myfunction(4, 6).get_return_value() + 1, 4, 6 + """ + + if not callable(__function): + def wrapper(func: TargetFunction) -> FullParamReturn: + return threaded( + func, + args = args, + kwargs = kwargs, + ignore_errors = ignore_errors, + suppress_errors = suppress_errors, + **overflow_kwargs + ) + return wrapper + + overflow_kwargs.update({ + 'ignore_errors': ignore_errors, + 'suppress_errors': suppress_errors + }) + + kwargs = dict(kwargs) + + @wraps(__function) + def wrapped(*parsed_args: P.args, **parsed_kwargs: P.kwargs) -> Thread: + kwargs.update(parsed_kwargs) + + processed_args = ( *args, *parsed_args ) + processed_kwargs = { i:v for i,v in parsed_kwargs.items() if i not in ['args', 'kwargs'] } + + job = Thread( + target = __function, + args = processed_args, + kwargs = processed_kwargs, + **overflow_kwargs + ) + job.start() + return job + + return wrapped + + diff --git a/src/thread/exceptions.py b/src/thread/exceptions.py index bdc6048..881dee3 100644 --- a/src/thread/exceptions.py +++ b/src/thread/exceptions.py @@ -1,8 +1,14 @@ +""" +## Thread Exceptions + +Documentation: https://thread.ngjx.org +""" + import traceback from typing import Any, Optional, Sequence, Tuple -class ThreadErrorBase(Exception): +class ErrorBase(Exception): """Base exception class for all errors within this library""" message: str = 'Something went wrong!' def __init__(self, message: Optional[str] = None, *args: Any, **kwargs: Any) -> None: @@ -10,19 +16,21 @@ def __init__(self, message: Optional[str] = None, *args: Any, **kwargs: Any) -> super().__init__(message, *args, **kwargs) -class ThreadStillRunningError(ThreadErrorBase): + +# THREAD ERRORS # +class ThreadStillRunningError(ErrorBase): """Exception class for attempting to invoke a method which requries the thread not be running, but isn't""" message: str = 'Thread is still running, unable to invoke method. You can wait for the thread to terminate with `Thread.join()` or check with `Thread.is_alive()`' -class ThreadNotRunningError(ThreadErrorBase): +class ThreadNotRunningError(ErrorBase): """Exception class for attempting to invoke a method which requires the thread to be running, but isn't""" message: str = 'Thread is not running, unable to invoke method. Have you ran `Thread.start()`?' -class ThreadNotInitializedError(ThreadErrorBase): +class ThreadNotInitializedError(ErrorBase): """Exception class for attempting to invoke a method which requires the thread to be initialized, but isn't""" message: str = 'Thread is not initialized, unable to invoke method.' -class HookRuntimeError(ThreadErrorBase): +class HookRuntimeError(ErrorBase): """Exception class for hook runtime errors""" message: str = 'Encountered runtime errors in hooks' count: int = 0 @@ -44,13 +52,3 @@ def __init__(self, message: Optional[str] = '', extra: Sequence[Tuple[Exception, new_message += f'{trace}\n{v[0]}' new_message += '<<<<<<<<<<' super().__init__(new_message) - - - # Python 3.9 doesn't support Exception.add_note() - # def add_exception_case(self, func_name: str, error: Exception): - # self.count += 1 - # trace = '\n'.join(traceback.format_stack()) - - # self.add_note(f'\n{self.count}. {func_name}\n>>>>>>>>>>') - # self.add_note(f'{trace}\n{error}') - # self.add_note('<<<<<<<<<<') diff --git a/src/thread/thread.py b/src/thread/thread.py index 6c0512e..92b42a2 100644 --- a/src/thread/thread.py +++ b/src/thread/thread.py @@ -1,35 +1,32 @@ +""" +## Core of thread + +```py +class Thread: ... +class ParallelProcessing: ... +``` + +Documentation: https://thread.ngjx.org +""" + import sys import time import signal import threading +from functools import wraps from . import exceptions from .utils.config import Settings from .utils.algorithm import chunk_split -from functools import wraps +from ._types import ThreadStatus, Data_In, Data_Out, Overflow_In, TargetFunction, HookFunction from typing import ( Any, List, - Callable, Union, Optional, Literal, + Callable, Optional, Mapping, Sequence, Tuple ) -ThreadStatus = Literal[ - 'Idle', - 'Running', - 'Invoking hooks', - 'Completed', - - 'Errored', - 'Kill Scheduled', - 'Killed' -] -Data_In = Any -Data_Out = Any -Overflow_In = Any - - Threads: set['Thread'] = set() class Thread(threading.Thread): """ @@ -40,7 +37,7 @@ class Thread(threading.Thread): """ status : ThreadStatus - hooks : List[Callable[[Data_Out], Union[Any, None]]] + hooks : List[HookFunction] returned_value: Data_Out errors : List[Exception] @@ -54,7 +51,7 @@ class Thread(threading.Thread): def __init__( self, - target: Callable[..., Data_Out], + target: TargetFunction, args: Sequence[Data_In] = (), kwargs: Mapping[str, Data_In] = {}, ignore_errors: Sequence[type[Exception]] = (), @@ -103,7 +100,7 @@ def __init__( ) - def _wrap_target(self, target: Callable[..., Data_Out]) -> Callable[..., Data_Out]: + def _wrap_target(self, target: TargetFunction) -> TargetFunction: """Wraps the target function""" @wraps(target) def wrapper(*args: Any, **kwargs: Any) -> Any: @@ -211,7 +208,7 @@ def is_alive(self) -> bool: return super().is_alive() - def add_hook(self, hook: Callable[[Data_Out], Union[Any, None]]) -> None: + def add_hook(self, hook: HookFunction) -> None: """ Adds a hook to the thread ------------------------- @@ -347,7 +344,7 @@ class ParallelProcessing: def __init__( self, - function: Callable[..., Data_Out], + function: TargetFunction, dataset: Sequence[Data_In], max_threads: int = 8, @@ -388,7 +385,7 @@ def __init__( def _wrap_function( self, - function: Callable[..., Data_Out] + function: TargetFunction ) -> Callable[..., List[Data_Out]]: @wraps(function) def wrapper(index: int, data_chunk: Sequence[Data_In], *args: Any, **kwargs: Any) -> List[Data_Out]: diff --git a/tests/test_decorator.py b/tests/test_decorator.py new file mode 100644 index 0000000..53117d2 --- /dev/null +++ b/tests/test_decorator.py @@ -0,0 +1,55 @@ +import time +import pytest +from src.thread import threaded, exceptions + + +# >>>>>>>>>> Dummy Functions <<<<<<<<<< # +def _dummy_target_raiseToPower(x: float, power: float, delay: float = 0): + time.sleep(delay) + return x**power + +def _dummy_raiseException(x: Exception, delay: float = 0): + time.sleep(delay) + raise x + +def _dummy_iterative(itemCount: int, pTime: float = 0.1, delay: float = 0): + time.sleep(delay) + for i in range(itemCount): + time.sleep(pTime) + + + + +# >>>>>>>>>> General Use <<<<<<<<<< # +def test_creationNoParam(): + @threaded + def _run(*args): + return _dummy_target_raiseToPower(*args) + + x = _run(2, 2) + assert x.get_return_value() == 4 + +def test_creationEmptyParam(): + @threaded() + def _run(*args): + return _dummy_target_raiseToPower(*args) + + x = _run(2, 2) + assert x.get_return_value() == 4 + +def test_creationWithParam(): + @threaded(daemon = True) + def _run(*args): + return _dummy_target_raiseToPower(*args) + + x = _run(2, 2) + assert x.daemon + assert x.get_return_value() == 4 + +def test_argJoin(): + @threaded(daemon = True, args = (1, 2, 3)) + def _run(*args): + return args + + x = _run(8, 9) + assert x.get_return_value() == (1, 2, 3, 8, 9)