diff --git a/.github/workflows/create-documentation.yaml b/.github/workflows/create-documentation.yaml deleted file mode 100644 index f87f5c1..0000000 --- a/.github/workflows/create-documentation.yaml +++ /dev/null @@ -1 +0,0 @@ -# TODO \ No newline at end of file diff --git a/.github/workflows/launch-container.yaml b/.github/workflows/launch-container.yaml new file mode 100644 index 0000000..b4d99aa --- /dev/null +++ b/.github/workflows/launch-container.yaml @@ -0,0 +1,30 @@ +name: Container Launcher + +on: + workflow_call: + inputs: + command: + required: true + description: The command to run + type: string + args: + required: false + description: Arguments to pass to the command + type: string + +jobs: + execute: + name: Execute Command in Container + runs-on: ubuntu-latest + env: + image_tag: hephaestus-dev:latest + app_dir: /dev/hephaestus + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + + - name: Build Container + run: docker build --file docker/Dockerfile --tag $image_tag . + + - name: Run Command Using Container + run: docker run --rm --volume .:$app_dir --workdir $app_dir $image_tag ${{ inputs.command }} ${{ inputs.args }} \ No newline at end of file diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index f87f5c1..fc49699 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -1 +1,17 @@ -# TODO \ No newline at end of file +name: Run Python Tests + +on: + push: + branches: + - develop + pull_request: + branches: + - develop + +jobs: + test: + name: Setup and Run Test Suite + uses: ./.github/workflows/launch-container.yaml + with: + command: scripts/run_pytest + \ No newline at end of file diff --git a/README.md b/README.md index 55c0c09..a023a69 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ All modules are still referenced like so: ``` # myfile.py -from hephaestus.common.colors import Colors +from hephaestus.common.constants import AnsiColors from hephaestus.testing.pytest.fixtures import * ``` diff --git a/config/sphinx/source/conf.py b/config/sphinx/source/conf.py index 2b47928..3fee695 100644 --- a/config/sphinx/source/conf.py +++ b/config/sphinx/source/conf.py @@ -11,17 +11,22 @@ sys.path.append(str(Path(__file__).parents[3])) -project = 'Hephaestus' -copyright = '2024, Malakai Spann' -author = 'Malakai Spann' -release = '0.1.0' +project = "Hephaestus" +copyright = "2025, Malakai Spann" +author = "Malakai Spann" +release = "0.1.0" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinx.ext.todo", "sphinx.ext.viewcode"] +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", + "sphinx.ext.todo", + "sphinx.ext.viewcode", +] -templates_path = ['_templates'] +templates_path = ["_templates"] exclude_patterns = [] add_module_names = False @@ -29,5 +34,5 @@ # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -html_theme = 'sphinx_rtd_theme' -html_static_path = ['_static'] \ No newline at end of file +html_theme = "sphinx_rtd_theme" +html_static_path = ["_static"] diff --git a/docker/Dockerfile b/docker/Dockerfile index dc00bfa..7bbef7b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.6.15-slim-buster +FROM python:3.10.16-slim-bullseye LABEL author "Malakai Spann" LABEL maintainers ["malakaispann@gmail.com",] @@ -6,6 +6,7 @@ LABEL title "Hephaestus Dev" LABEL description "The official Docker Image for developing, testing, and building the Hephaestus library." # IMPORTANT: All instructions assume the build context is from the root of the repo. +COPY config/dev.requirements.txt /tmp/requirements.txt # Upgrade Package Management OS RUN apt-get update && \ @@ -13,5 +14,5 @@ RUN apt-get update && \ # Install Python packages RUN pip install --upgrade pip && \ - pip install --requirements config/dev.requirements.txt + pip install --requirement /tmp/requirements.txt diff --git a/hephaestus/_internal/meta.py b/hephaestus/_internal/meta.py index 5742635..9684002 100644 --- a/hephaestus/_internal/meta.py +++ b/hephaestus/_internal/meta.py @@ -1,7 +1,18 @@ from pathlib import Path +PROJECT_NAME: str = "Hephaestus" +PROJECT_SOURCE_URL: str = "https://github.com/KayDVC/hephaestus/" + +# TODO +PROJECT_DOCUMENTATION_URL: str = "" + class Paths: + """A collection of paths in the Hephaestus repo. + + Literally, not helpful to anyone but Hephaestus devs. + """ + ROOT = Path(__file__).parents[2].resolve() LOGS = Path(ROOT, "logs") LIB = Path(ROOT, "hephaestus") diff --git a/hephaestus/common/colors.py b/hephaestus/common/colors.py deleted file mode 100644 index da00ff1..0000000 --- a/hephaestus/common/colors.py +++ /dev/null @@ -1,7 +0,0 @@ -class Colors: - CYAN = "\033[36m" - GREEN = "\033[32m" - RED = "\033[31m" - YELLOW = "\033[33m" - MAGENTA = "\033[35m" - RESET = "\033[0m" diff --git a/hephaestus/common/constants.py b/hephaestus/common/constants.py index 59fea74..a9e1cc0 100644 --- a/hephaestus/common/constants.py +++ b/hephaestus/common/constants.py @@ -1 +1,31 @@ -EMPTY_STRING = "" +class AnsiColors: + """ANSI escape codes representing various colors.""" + + CYAN = "\033[36m" + GREEN = "\033[32m" + RED = "\033[31m" + YELLOW = "\033[33m" + MAGENTA = "\033[35m" + RESET = "\033[0m" + + +class CharConsts: + """Common characters.""" + + NULL = "" + SPACE = " " + UNDERSCORE = "_" + + +class StrConsts: + """Common strings.""" + + EMPTY_STRING = CharConsts.NULL + + +class Emojis: + """Common graphical symbols that represent various states or ideas.""" + + GREEN_CHECK = "✅" + RED_CROSS = "❌" + GOLD_MEDAL = "🏅" diff --git a/hephaestus/common/exceptions.py b/hephaestus/common/exceptions.py index e04faad..6418619 100644 --- a/hephaestus/common/exceptions.py +++ b/hephaestus/common/exceptions.py @@ -1,4 +1,9 @@ -from logging import getLogger +import textwrap +import logging + +from typing import Any + +from hephaestus._internal.meta import PROJECT_SOURCE_URL class LoggedException(Exception): @@ -6,10 +11,41 @@ class LoggedException(Exception): This class is meant to be used as the base class for any other custom exceptions. It logs the error message for later viewing. + There is only one argument added to the basic Exception `__init__` method; see args below. + + Args: + """ - _logger = getLogger(__name__) + _logger = logging.getLogger(__name__) + + def __init__( + self, + msg: Any = None, + log_level: int = logging.ERROR, + stack_level: int = 2, + *args, + ): + """ + Args: + msg: the error message to log. Defaults to None. + log_level: the level to log the message at. Defaults to ERROR. + stack_level: the number of calls to peek back in the stack trace for + log info such as method name, line number, etc. Defaults to 2. + """ + self._logger.log(level=log_level, msg=msg, stacklevel=stack_level) + super().__init__(msg, *args) + + +class _InternalError(LoggedException): + """Indicates a problem with the library's code was encountered.""" - def __init__(self, msg: str = None): - self._logger.error(msg) - super().__init__(msg) + def __init__(self, msg: Any = None, *args): + msg = textwrap.dedent( + f"""\ + Encountered an internal error with the Hephaestus Library: + \t{msg} + \tPlease report this issue here: {PROJECT_SOURCE_URL} + """ + ) + super().__init__(msg, stack_level=3, *args) diff --git a/hephaestus/common/types.py b/hephaestus/common/types.py index 72f6293..53935ba 100644 --- a/hephaestus/common/types.py +++ b/hephaestus/common/types.py @@ -1,7 +1,24 @@ import os -from typing import Union +from typing import Any, Union from pathlib import Path PathLike = Union[str, Path, os.PathLike] + + +def to_bool(obj: Any): + """Converts any object to a boolean. + + Args: + obj: the object to convert. + + Returns: + True if the object has a sane truthy value; False otherwise. + """ + if not isinstance(obj, str): + return bool(obj) + + obj = obj.lower() + + return obj in ["true", "t", "yes", "y", "enable"] diff --git a/hephaestus/decorators/reference.py b/hephaestus/decorators/reference.py index 077e3f6..d29f8eb 100644 --- a/hephaestus/decorators/reference.py +++ b/hephaestus/decorators/reference.py @@ -30,7 +30,7 @@ def __init__(self, getter: Callable = None, ignores: list[Callable] = []): def __return_none() -> None: - """Empty function used to always get None on call. + """Empty method used to always get None on call. Returns: None @@ -42,12 +42,12 @@ def __method_wrapper(getter: Callable, if_none: Callable, method_name: str) -> C """Wraps methods, ensuring calls go to object stored by reference. Args: - getter: the function to use a "getter" for the stored object. - if_none: the function to use should the stored object be Null. + getter: the method to use a "getter" for the stored object. + if_none: the method to use should the stored object be Null. method_name: the name of method to wrap. Returns: - A callable function that acts as a proxy for the method to wrap. + A callable method that acts as a proxy for the method to wrap. Note: Any args passed to the wrapped method will be passed to the stored object's @@ -131,7 +131,7 @@ def reference_getter(method: Callable) -> Callable: # Set modifier indicators for this method. Here, we want this method to have # both the getter and ignore indicator so that our reference logic will skip wrapping the - # function without adding an extra conditional check. + # method without adding an extra conditional check. setattr(method, __getter_id, True) cls_map.getter = method_name setattr(method, __ignore_id, True) @@ -186,7 +186,7 @@ def __new__(cls: Type, *args, **kwargs): if (not cls_map) or (not cls_map.getter) or (not hasattr(cls, cls_map.getter)): raise ReferenceError("Could not find getter for class.") - # Get the function objects to pass to the wrapper methods. + # Get the "getter" method to pass to the wrapper methods. getter_ = getattr(cls, cls_map.getter) if_none_method_ = __return_none # TODO: make configurable as a param? diff --git a/hephaestus/decorators/track.py b/hephaestus/decorators/track.py index 9d2e343..63ac31b 100644 --- a/hephaestus/decorators/track.py +++ b/hephaestus/decorators/track.py @@ -2,7 +2,7 @@ from queue import Queue from typing import Callable -from hephaestus.util.logging import get_logger +from hephaestus.io.logging import get_logger from hephaestus.patterns.singleton import Singleton _logger = get_logger(__name__) @@ -10,35 +10,38 @@ ## # Public ## -FunctionTrace = namedtuple("FunctionTrace", ["name", "args", "kwargs", "retval"]) +MethodTrace = namedtuple("MethodTrace", ["name", "args", "kwargs", "retval"]) -class Trace_Queue(Queue, metaclass=Singleton): +class TraceQueue(Queue, metaclass=Singleton): """An object capable of storing""" - def get() -> FunctionTrace: - """_summary_ + def get(self) -> MethodTrace: + """Returns the last trace. Returns: - _description_ + The last method trace containing the method's name, the passed + positional arguments, keyword arguments, and returned value, if any. """ - - retval = None - - if retval: - retval = super().get() + retval = None if self.empty() else super().get() return retval + def clear(self): + """Removes all MethodTraces from memory.""" + _logger.debug("Clearing trace queue.") + while not self.empty(): + _ = self.get() + def track(to_track: Callable) -> Callable: - """Records function call for later examination. + """Records method call for later examination. Args: - to_track : the function to track. + to_track : the method to track. Returns: - The passed function with minor modification pre and post-call + The passed method with minor modification pre and post-call to support tracking capability. Note: @@ -48,24 +51,22 @@ def track(to_track: Callable) -> Callable: def print_copy(*args): ... - Or like a regular function: + Or like a regular method: print_copy = track(to_track=print_copy) """ def wrapper(*args, **kwargs): - """Forward all function parameters to wrapped function.""" + """Forward all method parameters to wrapped method.""" _logger.debug( f"Traced method: {to_track.__name__}, Args: {args}, Keyword Args: {kwargs}" ) - # Call function and store in queue. + # Call method and store in queue. retval = to_track(*args, **kwargs) - Trace_Queue().put( - FunctionTrace( - name=to_track.__name__, args=args, kwargs=kwargs, retval=retval - ) + TraceQueue().put( + MethodTrace(name=to_track.__name__, args=args, kwargs=kwargs, retval=retval) ) _logger.debug(f"Method returned. Return value: {retval}") diff --git a/hephaestus/io/file.py b/hephaestus/io/file.py index c0d9142..9bf608b 100644 --- a/hephaestus/io/file.py +++ b/hephaestus/io/file.py @@ -3,7 +3,7 @@ from hephaestus.common.exceptions import LoggedException from hephaestus.common.types import PathLike -from hephaestus.util.logging import get_logger +from hephaestus.io.logging import get_logger _logger = get_logger(__name__) diff --git a/hephaestus/util/logging.py b/hephaestus/io/logging.py similarity index 87% rename from hephaestus/util/logging.py rename to hephaestus/io/logging.py index c3920e0..5f30e79 100644 --- a/hephaestus/util/logging.py +++ b/hephaestus/io/logging.py @@ -7,12 +7,12 @@ from typing import Callable, Optional from hephaestus.common.types import PathLike -from hephaestus.common.colors import Colors +from hephaestus.common.constants import AnsiColors """ A wrapper for the logging interface that ensures a consistent logging experience. - Many classes utility functions in this file are defined elsewhere for public use, however, + Many classes utility method in this file are defined elsewhere for public use, however, their use would cause a circular dependency. """ @@ -65,19 +65,19 @@ class LogFormatter(logging.Formatter): DEFAULT_TIME_EXPR = time.gmtime DEFAULT_FORMAT_OPTS = { logging.DEBUG: FormatOptions( - fmt=_VERBOSE_FMT, default_color=Colors.MAGENTA, style=_FMT_STYLE + fmt=_VERBOSE_FMT, default_color=AnsiColors.MAGENTA, style=_FMT_STYLE ), logging.INFO: FormatOptions( - fmt=_SHORT_FMT, default_color=Colors.CYAN, style=_FMT_STYLE + fmt=_SHORT_FMT, default_color=AnsiColors.CYAN, style=_FMT_STYLE ), logging.WARNING: FormatOptions( - fmt=_VERBOSE_FMT, default_color=Colors.YELLOW, style=_FMT_STYLE + fmt=_VERBOSE_FMT, default_color=AnsiColors.YELLOW, style=_FMT_STYLE ), logging.ERROR: FormatOptions( - fmt=_VERBOSE_FMT, default_color=Colors.RED, style=_FMT_STYLE + fmt=_VERBOSE_FMT, default_color=AnsiColors.RED, style=_FMT_STYLE ), logging.CRITICAL: FormatOptions( - fmt=_VERBOSE_FMT, default_color=Colors.RED, style=_FMT_STYLE + fmt=_VERBOSE_FMT, default_color=AnsiColors.RED, style=_FMT_STYLE ), } @@ -142,7 +142,7 @@ def format(self, record): if not self._enable_color: return formatted_str - return f"{getattr(record, "color", self.DEFAULT_FORMAT_OPTS[record.levelno].default_color)}{formatted_str}{Colors.RESET}" + return f"{getattr(record, 'color', self.DEFAULT_FORMAT_OPTS[record.levelno].default_color)}{formatted_str}{AnsiColors.RESET}" ## @@ -246,16 +246,12 @@ def configure_root_logger( logger.addHandler(handler) -def get_logger( - name: str = None, root: PathLike = None, file_path: PathLike = None -) -> logging.Logger: +def get_logger(name: str = None, root: PathLike = None) -> logging.Logger: """Creates a log of application activity. Args: name: the name of the calling module. Defaults to None. root: the path to the root of the project. Defaults to none. - file_path: the path to the file where this logger will be used. Only use this - parameter when fine control over logger naming is necessary. Returns: A bare logger object that accepts all default levels of log messages. @@ -264,8 +260,6 @@ def get_logger( If the calling file is not in the project and the `name` arg is provided, this acts just like logging.getLogger(name). If the file is in the project AND the `root` arg is provided, the name will be relative to the root of the project. - If `file_path` is specified, it will be used to name the logger instead of the calling - file. """ # Convert supplied path to absolute path. if root: @@ -274,11 +268,7 @@ def get_logger( # Get logger if root and root.exists(): try: - file_path = ( - Path(file_path).resolve() - if file_path - else Path(inspect.stack()[1].filename).resolve() - ) + file_path = Path(inspect.stack()[1].filename).resolve() name = ".".join(file_path.relative_to(root).with_suffix("").parts) except ValueError: pass diff --git a/hephaestus/io/subprocess.py b/hephaestus/io/subprocess.py index 5e7e6a8..49df438 100644 --- a/hephaestus/io/subprocess.py +++ b/hephaestus/io/subprocess.py @@ -3,14 +3,16 @@ from typing import Any, Callable -from hephaestus.common.exceptions import LoggedException -from hephaestus.util.logging import get_logger -from hephaestus.io.stream import LogStreamer +from hephaestus.common.exceptions import LoggedException, _InternalError +from hephaestus.io.logging import get_logger _logger = get_logger(__name__) -# TODO: option to pass the information of the actual caller +class _SubprocessError(Exception): + pass + + def _exec( cmd: list[Any], enable_output: bool = False, @@ -25,11 +27,15 @@ def _exec( enable_output: whether to log captured output. log_level: the level to log cmd output at. Ignored if enable_output set to False. Defaults to DEBUG. + Raises: + _Subprocess_Error if the command fails after running. + Any other exception thrown means there was an issue in the Python runtime logic. + Returns: The output of the cmd as captured line-by-line. If the command was unsuccessful, None will be returned. Notes: - This function overwrites various commonly set arguments to subprocess.run/subprocess.popen including + This method overwrites various commonly set arguments to subprocess.run/subprocess.popen including `stdout`, `stderr`, and `universal_newlines`. Users should only expect `enable_output` to change the behavior of what's actually output. @@ -49,11 +55,12 @@ def _exec( # Make line endings OS-agnostic kwargs["universal_newlines"] = True - # The performance might matter enough here to repeat myself :(. - cmd_output = [] - retcode = None try: + cmd_output = [] + retcode = None with subprocess.Popen(cmd, *args, **kwargs) as process: + + # The performance might matter enough here to repeat myself :(. if enable_output: _logger.log(level=log_level, msg="Cmd Output:") for line in process.stdout: @@ -62,14 +69,18 @@ def _exec( _logger.log(level=log_level, msg=line) else: for line in process.stdout: - line = line.strip() - cmd_output.append(line) + cmd_output.append(line.strip()) retcode = process.wait() - except Exception: - pass - return cmd_output if retcode == 0 else None + # Seriously bad juju here: the code is FUBAR, not the command. Log it. + except Exception as e: + raise _InternalError(e) + + if retcode != 0: + raise _SubprocessError + + return cmd_output ## @@ -81,18 +92,35 @@ class SubprocessError(LoggedException): pass -def command_successful(cmd: list[Any]): +def command_successful(cmd: list[Any], cleanup: Callable = None): """Checks if command returned 'Success' status. Args: cmd: the command to run. + cleanup: the method to run in the event of a failure. Defaults to None. Note: - This function doesn't capture or return any command output. + This method doesn't capture or return any command output. It's intended to be used in pass/fail-type scenarios involving subprocesses. """ - return _exec(cmd, enable_output=False) is not None + success = True + + try: + _exec(cmd, enable_output=False) is not None + + # Execute cleanup on most exceptions, if available. + except Exception as e: + success = False + + if cleanup: + cleanup() + + # Panic + if not isinstance(e, _SubprocessError): + raise + + return success def run_command( @@ -109,28 +137,71 @@ def run_command( Args: cmd: the command to run. err: the error to display if the command fails. - cleanup: the function to run in the event of a failure. + cleanup: the method to run in the event of a failure. Defaults to None. enable_output: whether to log captured output. Defaults to True. log_level: the level to log cmd output at. Ignored if enable_output set to False. Defaults to DEBUG. Raises: - SubprocessError if the command fails for any reason. + SubprocessError if the command fails to return a "success" status. Notes: - This function overwrites various commonly set arguments to subprocess.run/subprocess.popen including + This method overwrites various commonly set arguments to subprocess.run/subprocess.popen including `stdout`, `stderr`, and `universal_newlines`. Users should only expect `enable_output` to change the behavior of what's actually output. """ try: - if ( - _exec( - cmd, enable_output=enable_output, log_level=log_level, *args, **kwargs - ) - is None - ): + _ = _exec( + cmd, enable_output=enable_output, log_level=log_level, *args, **kwargs + ) + + # Execute cleanup on most exceptions, if available. + except Exception as e: + if cleanup: + cleanup() + + # Command failed after running. Log user provided error message. + if isinstance(e, _SubprocessError): raise SubprocessError(err) - except SubprocessError: + + # Panic + raise + + +def get_command_output( + cmd: list[Any], + err: str, + cleanup: Callable = None, + *args, + **kwargs, +): + """Runs command and logs output as specified. + + Args: + cmd: the command to run. + err: the error to display if the command fails. + cleanup: the method to run in the event of a failure. Defaults to None. + + Raises: + SubprocessError if the command fails to return a "success" status. + + Notes: + Output via logging is completely disabled here. It's up to the user to + log the command's output. + """ + try: + output = _exec(cmd, enable_output=False, log_level=None, *args, **kwargs) + + # Execute cleanup on most exceptions, if available. + except Exception as e: if cleanup: cleanup() + + # Command failed after running. Log user provided error message. + if isinstance(e, _SubprocessError): + raise SubprocessError(err) + + # Panic raise + + return output diff --git a/hephaestus/patterns/singleton.py b/hephaestus/patterns/singleton.py index 46a479c..8312ad6 100644 --- a/hephaestus/patterns/singleton.py +++ b/hephaestus/patterns/singleton.py @@ -1,10 +1,10 @@ -from threading import Lock +import threading -from hephaestus.util.logging import get_logger +from typing import Any, Type -_logger = get_logger(__name__) +from hephaestus.io.logging import get_logger -_singleton_lock = Lock() +_logger = get_logger(__name__) class Singleton(type): @@ -15,14 +15,32 @@ class Singleton(type): ... Each Singleton object will have access to a standard library thread mutex via `self._lock` for basic thread safety. - It is the responsibility of the subclass implementer to ensure operations are atomic. + The type of mutex can be changed by calling the set_lock_type method. - Final Warnings: - - If overriding `__call__`, be sure to reference the `__call__` method as implemented in this class. - - Do not modify `__shared_instances`. + It is the responsibility of the subclass implementation to ensure ALL operations are atomic. + + Note: + - Do not directly modify `__shared_instances` nor `__singleton_lock`. """ + ## + # Constants + ## + __DEFAULT_LOCK_TYPE: Type = threading.Lock + __LOCK_ATTR_KEY: str = "_lock" + __INSTANCE_ATTR_KEY: str = "_instance" + + # Public Access + DEFAULT_LOCK_TYPE: Type = __DEFAULT_LOCK_TYPE + LOCK_ATTR_KEY: str = __LOCK_ATTR_KEY + INSTANCE_ATTR_KEY: str = __INSTANCE_ATTR_KEY + + ## + # "Private" Class Vars + ## + __lock_type: Type = __DEFAULT_LOCK_TYPE __shared_instances = {} + __singleton_lock = __lock_type() def __call__(cls, *args, **kwargs): """Initializes or returns available singleton objects.""" @@ -30,20 +48,113 @@ def __call__(cls, *args, **kwargs): # Check for object instance before locking. if cls not in cls.__shared_instances: - # Acquire lock and re-check, creating instance as necessary. - with _singleton_lock: + # Acquire lock and re-check. Add the attributes necessary for the class to handle + # its own singleton instantiation. + with cls.__singleton_lock: if cls not in cls.__shared_instances: - _logger.debug( - f"Shared instance not available for {cls.__name__}. Creating...", - stacklevel=2, + f"Known instance of {cls.__name__} not available.", ) - - cls.__shared_instances[cls] = super(Singleton, cls).__call__( - *args, **kwargs + if not ( + hasattr(cls, cls.__LOCK_ATTR_KEY) + and isinstance( + hasattr(cls, cls.__LOCK_ATTR_KEY), cls.__lock_type + ) + ): + setattr(cls, cls.__LOCK_ATTR_KEY, cls.__lock_type()) + _logger.debug( + f"Created lock of type {cls.__lock_type.__name__} for {cls.__name__}.", + ) + if not hasattr(cls, cls.__INSTANCE_ATTR_KEY): + setattr(cls, cls.__INSTANCE_ATTR_KEY, None) + + # Prevent race conditions in between releasing this lock and actual instantiation. + cls.__shared_instances[cls] = None + + # Follow same double-checked locking pattern here, except, let the class use its + # own mutex for locking. This should prevent deadlocks where a Singleton requires another + # Singleton during its instantiation. + + # Also, using the class's fully-qualified name as the key in `__shared_instances` is + # pretty safe since it should be different for every class. If not, it's probs not written correctly. + instance = getattr(cls, cls.__INSTANCE_ATTR_KEY) + lock = getattr(cls, cls.__LOCK_ATTR_KEY) + if not instance: + with lock: + if not instance: + instance = super().__call__(*args, **kwargs) # create object. + setattr(cls, cls.__INSTANCE_ATTR_KEY, instance) + cls.__shared_instances[cls] = instance + _logger.debug( + f"Created instance of {cls.__name__}.", ) + else: # Handle case: class already has instance. + with lock: + cls.__shared_instances[cls] = instance + + return getattr(cls, cls.__INSTANCE_ATTR_KEY) + - # Add lock for each instance. - cls._lock = Lock() +def get_lock_type() -> Type: + """Returns the current lock type for all Singleton objects. + + Returns: + The class used to enable atomic operations for all Singleton subclasses + as well as the Singleton instantiation logic. + """ + return Singleton._Singleton__lock_type + + +def set_lock_type(lock_type: Type) -> bool: + """Sets the lock type for all Singleton objects. + + Args: + lock_type: a type that, when instantiated, can enable atomic operations for shared data. + Must support use as a context manager (i.e. `with lock_type():`). + + Returns: + True if the pass lock_type was set; False otherwise. + + Note: + This should be called before any Singleton instantiation due to ensure safe operations. + """ - return cls.__shared_instances[cls] # return instance if available/once created + # Ensure we were given a class. + if not isinstance(lock_type, Type): + _logger.warning( + f"Lock Type {str(lock_type)} is not a class. Keeping current lock type: {get_lock_type().__name__}" + ) + + # Test with context management. This is what the singleton logic uses and probs what extenders will use. + try: + with lock_type(): + pass + except Exception: + _logger.warning( + f"Custom lock type cannot be used with context management. Keeping current lock type: {get_lock_type().__name__}. " + ) + return False + + # Change lock type. + current_singleton_lock = ( + Singleton._Singleton__singleton_lock + ) # Assignment to a local var here is not actually necessary, just helps me sleep better at night. + with current_singleton_lock: + Singleton._Singleton__lock_type = lock_type + Singleton._Singleton__singleton_lock = lock_type() + + return True + + +def clear_all(): + """Destroys all known Singleton instances.""" + _logger.debug("Clearing all known Singleton instances.") + with Singleton._Singleton__singleton_lock: + + shared_instances = Singleton._Singleton__shared_instances + + for cls in shared_instances.keys(): + if getattr(cls, Singleton.INSTANCE_ATTR_KEY): + with getattr(cls, Singleton.LOCK_ATTR_KEY): + setattr(cls, Singleton.INSTANCE_ATTR_KEY, None) + shared_instances[cls] = None diff --git a/hephaestus/testing/mock/threading.py b/hephaestus/testing/mock/threading.py new file mode 100644 index 0000000..ef39b01 --- /dev/null +++ b/hephaestus/testing/mock/threading.py @@ -0,0 +1,53 @@ +from typing import Any + +import logging +from hephaestus.common.exceptions import LoggedException + + +class MockMutexAbort(LoggedException): + def __init__(self, msg: Any): + super().__init__(msg=msg, log_level=logging.WARNING, stack_level=3) + + +class MockLock: + """A simple implementation of a Mutex without any frills. + + This class is capable of aborting an operation by calling MockLock.abort(). + """ + + _abort_operation = False + + def __init__(self): + self._locked = False + + def acquire(self): + """Waits until lock is available, then acquires the lock.""" + while self._locked: + + # Entirety of abort lock. Nice and simple. + if self._abort_operation: + self._abort_operation = False + raise MockMutexAbort("Aborting operation requiring lock.") + + continue + + self._locked = True + + def release(self): + """Releases a lock. Can be called even without lock being acquired.""" + self._locked = False + + @classmethod + def abort(cls): + """Releases deadlocked mutex.""" + cls._abort_operation = True + + ## + # Enable use as a context manager + ## + def __enter__(self): + return self.acquire() + + def __exit__(self, exception_type, *args, **kwargs) -> bool: + self.release() + return exception_type is MockMutexAbort diff --git a/hephaestus/testing/pytest/configure.py b/hephaestus/testing/pytest/configure.py index 7968722..260f3ce 100644 --- a/hephaestus/testing/pytest/configure.py +++ b/hephaestus/testing/pytest/configure.py @@ -9,7 +9,7 @@ from typing import Any from hephaestus.io.stream import LogStreamer, NullStreamer -from hephaestus.util.logging import get_logger, LogFormatter +from hephaestus.io.logging import get_logger, LogFormatter # PyTest Devs think everything should be private for some reason. import _pytest @@ -93,7 +93,7 @@ def _get_failure_repr(report: _pytest.reports.TestReport) -> str: """ return "\n".join( report.longreprtext.splitlines()[1:] - ) # The first line is an address to the function which is not useful + ) # The first line is an address to the method which is not useful @pytest.hookimpl(trylast=True) # Going last ensures out removal is not overridden. @@ -116,15 +116,15 @@ def pytest_configure(config: _pytest.config.Config) -> None: @pytest.hookimpl(wrapper=True, trylast=True) def pytest_runtest_makereport(item: _pytest.nodes.Item, call: _pytest.runner.CallInfo[None]) -> _pytest.reports.TestReport: # type: ignore - """Logs each function's docstring. + """Logs each method's docstring. Note: - This is done here instead of in the fixture because the function level fixtures don't run when a function is marked to be skipped. + This is done here instead of in the fixture because the method level fixtures don't run when a method is marked to be skipped. We always want to know what would be run and why it wasn't. """ test_report = yield - # Print function's docstring and name. + # Print method's docstring and name. if test_report.when == "setup": swte.small_banner( f"Name: {item.function.__name__}\nDescription: {item.function.__doc__}" @@ -151,7 +151,7 @@ def pytest_report_teststatus(report: _pytest.reports.CollectReport | _pytest.rep global test_execution_time global test_results - # This log_report can have any of the 5 outcomes in OUTCOME_MAPS. All functions only have "passed", "failed", or "skipped". + # This log_report can have any of the 5 outcomes in OUTCOME_MAPS. All methods only have "passed", "failed", or "skipped". log_report = _pytest.terminal.TestShortLogReport(*(yield)) outcome = log_report.category.upper() diff --git a/hephaestus/testing/pytest/fixtures/__init__.py b/hephaestus/testing/pytest/fixtures/__init__.py index 8a0500b..182848f 100644 --- a/hephaestus/testing/pytest/fixtures/__init__.py +++ b/hephaestus/testing/pytest/fixtures/__init__.py @@ -1,2 +1,8 @@ -from hephaestus.testing.pytest.fixtures.env import * -from hephaestus.testing.pytest.fixtures.logging import * +from hephaestus.testing.pytest.fixtures.env import reset_env +from hephaestus.testing.pytest.fixtures.logging import module_logger, logger + +__all__ = [ + "logger", + "module_logger", + "reset_env", +] diff --git a/hephaestus/testing/pytest/fixtures/env.py b/hephaestus/testing/pytest/fixtures/env.py index e5ab231..701036f 100644 --- a/hephaestus/testing/pytest/fixtures/env.py +++ b/hephaestus/testing/pytest/fixtures/env.py @@ -1,6 +1,6 @@ import pytest -from hephaestus.patterns.singleton import Singleton +import hephaestus.patterns.singleton as singleton @pytest.fixture(scope="function", autouse=True) @@ -9,4 +9,4 @@ def reset_env(): yield # Reset any shared memory - Singleton._Singleton__shared_instances.clear() + singleton.clear_all() diff --git a/hephaestus/testing/pytest/fixtures/logging.py b/hephaestus/testing/pytest/fixtures/logging.py index fe4537b..88066fc 100644 --- a/hephaestus/testing/pytest/fixtures/logging.py +++ b/hephaestus/testing/pytest/fixtures/logging.py @@ -1,8 +1,13 @@ import logging +import os import pytest import _pytest -from hephaestus.util.logging import get_logger +from pathlib import Path + +import hephaestus.testing.swte as swte +from hephaestus.common.constants import CharConsts +from hephaestus.io.logging import get_logger @pytest.fixture(scope="module", autouse=True) @@ -15,7 +20,13 @@ def module_logger(request: _pytest.fixtures.SubRequest): Yields: a logger configured with the name of the test module. """ - module_logger = get_logger(file_path=request.path) + module_path = Path(request.path).relative_to(Path(request.session.startpath)) + logger_name = str(module_path.with_suffix("")).replace(os.sep, ".") + + module_logger = get_logger(name=logger_name) + swte.large_banner( + request.module.__name__.upper().replace(CharConsts.UNDERSCORE, CharConsts.SPACE) + ) yield module_logger diff --git a/hephaestus/testing/swte.py b/hephaestus/testing/swte.py index 6020999..de53602 100644 --- a/hephaestus/testing/swte.py +++ b/hephaestus/testing/swte.py @@ -1,4 +1,4 @@ -from hephaestus.util.logging import get_logger +from hephaestus.io.logging import get_logger _logger = get_logger(__name__) MAX_PRINT_WIDTH = 80 @@ -6,13 +6,18 @@ SMALL_DIVIDER = "-" * MAX_PRINT_WIDTH -class Constants: - MAGIC_STRING_ONE = "DEADBEEF" - MAGIC_STRING_TWO = "BADDCAFE" +class StrConsts: + DEADBEEF = "DEADBEEF" + BADDCAFE = "BADDCAFE" + + +class IntConsts: + DEADBEEF = 0xDEADBEEF + BADDCAFE = 0xBADDCAFE ## -# Logging Functions +# Logging Methods ## def large_banner(msg: str, **kwargs): """Logs message between dividers. diff --git a/hephaestus/tests/conftest.py b/hephaestus/tests/conftest.py index 2a5e2a5..3961667 100644 --- a/hephaestus/tests/conftest.py +++ b/hephaestus/tests/conftest.py @@ -1,5 +1,2 @@ -import logging -import sys - from hephaestus.testing.pytest.fixtures import * from hephaestus.testing.pytest.configure import * diff --git a/hephaestus/tests/decorators/test_reference.py b/hephaestus/tests/decorators/test_reference.py index 804a3d8..febe7dd 100644 --- a/hephaestus/tests/decorators/test_reference.py +++ b/hephaestus/tests/decorators/test_reference.py @@ -25,8 +25,10 @@ def update(self, value: str): class TestReference: - def test_verify_getter_defined(self): + def test_verify_getter_defined(self, logger): """Verifies reference decorator throws exception when "getter" not specified.""" + + logger.debug("Testing something") with pytest.raises(ReferenceError) as execution: @reference @@ -43,7 +45,7 @@ def test_verify_class(self): with pytest.raises(ReferenceError) as execution: @reference - def FakeFunction(): + def FakeMethod(): pass assert "object is not a class" in str(execution.value) diff --git a/hephaestus/tests/decorators/test_track.py b/hephaestus/tests/decorators/test_track.py new file mode 100644 index 0000000..ec4f419 --- /dev/null +++ b/hephaestus/tests/decorators/test_track.py @@ -0,0 +1,49 @@ +import hephaestus.testing.swte as swte +from hephaestus.decorators.track import TraceQueue, track + + +class TestTrack: + + FAKE_FUNCTION_RETURN_VALUE = 5 + + def _fake_function(self, *args, **kwargs): + return self.FAKE_FUNCTION_RETURN_VALUE + + def test_track_function( + self, + ): + """Verifies tracker records a method call""" + + tq = TraceQueue() + + wrapped_fake_function = track(self._fake_function) + wrapped_fake_function() + + assert tq.get().name == self._fake_function.__name__ + + def test_track_arguments(self): + """Verifies tracker records the arguments passed to a method.""" + + args = (swte.IntConsts.BADDCAFE, swte.IntConsts.DEADBEEF) + kwargs = { + "DEADBEEF": swte.StrConsts.DEADBEEF, + "BADDCAFE": swte.StrConsts.BADDCAFE, + } + + tq = TraceQueue() + + wrapped_fake_function = track(self._fake_function) + wrapped_fake_function(*args, **kwargs) + + traced = tq.get() + assert traced.args == args and traced.kwargs == kwargs + + def test_trace_return_value(self): + """Verifies tracker records the return value of a method""" + + tq = TraceQueue() + + wrapped_fake_function = track(self._fake_function) + wrapped_fake_function() + + assert tq.get().retval == self.FAKE_FUNCTION_RETURN_VALUE diff --git a/hephaestus/tests/patterns/test_singleton.py b/hephaestus/tests/patterns/test_singleton.py index 2de1401..a564451 100644 --- a/hephaestus/tests/patterns/test_singleton.py +++ b/hephaestus/tests/patterns/test_singleton.py @@ -1,4 +1,17 @@ -from hephaestus.patterns.singleton import Singleton +import threading + +from queue import Queue +from typing import Callable + +from hephaestus.io.logging import get_logger +from hephaestus.patterns.singleton import set_lock_type, get_lock_type, Singleton +from hephaestus.testing.swte import StrConsts +from hephaestus.testing.mock.threading import MockLock + +# Annotations +import logging + +_logger = get_logger(__name__) class FakeClassOne(metaclass=Singleton): @@ -11,25 +24,119 @@ class FakeClassTwo(metaclass=Singleton): class TestSingleton: - def test_same_instance(self): + def _execute_possible_deadlock_test( + self, test_case: Callable, expect_deadlock: bool = False + ): + + fail_buffer_secs = 2 + + # We'll want to use an abortable mutex to ensure test suite it affected. + _logger.debug("Changing singleton to use MockLock class.") + assert set_lock_type(lock_type=MockLock) + + # Share data between threads using atomic operations. + shared_queue = Queue() + shared_queue.put(_logger) + shared_queue.put(MockLock) + + def handle_deadlock(shared_queue: Queue): + + logger = shared_queue.get() + logger.warning("Deadlock detected. Attempting to recover.") + + # Empty queue and abort test case + MockLock = shared_queue.get() + MockLock.abort() + + _logger.debug(f"Setting a {fail_buffer_secs}s test case timeout.") + fail_timer = threading.Timer( + interval=2, function=handle_deadlock, kwargs={"shared_queue": shared_queue} + ) + fail_timer.start() + + _logger.debug(f"Running test case.") + test_case() + + fail_timer.cancel() + + # Revert any changes made during test + assert set_lock_type(lock_type=Singleton.DEFAULT_LOCK_TYPE) and ( + get_lock_type() is Singleton.DEFAULT_LOCK_TYPE + ) + assert shared_queue.empty() == expect_deadlock + + def test_deadlock_helper(self): + """Verifies utility method properly detects and fails a test case when it triggers a deadlock.""" + + def guaranteed_deadlock(): + lock = MockLock() + with lock: + with lock: + pass + + self._execute_possible_deadlock_test( + test_case=guaranteed_deadlock, expect_deadlock=True + ) + + def test_same_instance(self, logger: logging.Logger): """Verifies multiple instantiation attempts on a single class only result in one object.""" + logger.info("Creating objects") obj1 = FakeClassOne() obj2 = FakeClassOne() + logger.info("Comparing objects") assert obj1 is obj2 - def test_has_mutex(self): + def test_has_mutex(self, logger: logging.Logger): """Verifies each object is created with a mutex.""" + logger.info("Creating objects") obj1 = FakeClassOne() - assert hasattr(obj1, "_lock") + logger.info("Checking for mutex") + assert hasattr(obj1, Singleton.LOCK_ATTR_KEY) - def test_different_mutex_objects(self): + def test_different_mutex_objects(self, logger: logging.Logger): """Verifies each object has a personal mutex.""" + logger.info("Creating objects") obj1 = FakeClassOne() obj2 = FakeClassTwo() - assert obj1._lock is not obj2._lock + logger.info( + "Checking uniqueness of mutexes" + ) # mutexes? muticies? mutecie? may it's weird and always spelled with the singular version like moose. + assert getattr(obj1, Singleton.LOCK_ATTR_KEY) is not getattr( + obj2, Singleton.LOCK_ATTR_KEY + ) + + def test_persistent_data(self, logger: logging.Logger): + """Verifies each instantiation of an object has the same data available.""" + + class FakeClassWithData(metaclass=Singleton): + def __init__(self): + self.data = [] + + logger.info("Creating objects") + obj1 = FakeClassWithData() + + obj1.data.append(StrConsts.DEADBEEF) + + obj2 = FakeClassWithData() + + logger.info("Checking for persistent data across both objects") + assert obj1.data == obj2.data + + def test_nested_instantiation(self, logger): + """Verifies Singleton objects do not cause deadlocks when instantiated + inside other Singleton objects""" + + class ParentSingleton(metaclass=Singleton): + def __init__(self): + self.nested_singleton = FakeClassOne() + + def test_case(): + _ = ParentSingleton() + + self._execute_possible_deadlock_test(test_case=test_case, expect_deadlock=False) diff --git a/hephaestus/tests/util/test_logging.py b/hephaestus/tests/util/test_logging.py index adc68ef..fb577a1 100644 --- a/hephaestus/tests/util/test_logging.py +++ b/hephaestus/tests/util/test_logging.py @@ -1,5 +1,5 @@ -import hephaestus.util.logging as logging -import hephaestus.testing.swte as swte +from hephaestus.io.logging import get_logger +from hephaestus.testing.swte import StrConsts class TestLogging: @@ -8,11 +8,11 @@ def test_basic_logger(self): """Verifies that base logger name functionality is unchanged""" # Loggers with the same name should be the same object. - assert logging.get_logger( - name=swte.Constants.MAGIC_STRING_ONE - ) is logging.get_logger(name=swte.Constants.MAGIC_STRING_ONE) + assert get_logger(name=StrConsts.DEADBEEF) is get_logger( + name=StrConsts.DEADBEEF + ) # Loggers with different names should be different objects. - assert logging.get_logger( - name=swte.Constants.MAGIC_STRING_ONE - ) is not logging.get_logger(name=swte.Constants.MAGIC_STRING_TWO) + assert get_logger(name=StrConsts.DEADBEEF) is not get_logger( + name=StrConsts.BADDCAFE + ) diff --git a/pyproject.toml b/pyproject.toml index 8022438..4849b35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "hephaestus-lib" -version = "0.1.1" +version = "0.2.0" readme = "README.md" authors = [ {name="Malakai Spann", email="MalakaiSpann@gmail.com"} ] -description = "A collection of useful Python Classes, Functions, and Constants" -requires-python = ">=3.6" +description = "A collection of useful Python Classes, Methods, and Constants" +requires-python = ">=3.10" license = {file = "LICENSE"} classifiers = [ 'Intended Audience :: Developers', @@ -33,5 +33,9 @@ Repository = "https://github.com/KayDVC/hephaestus/" [tool.setuptools.packages.find] where = ["."] include = ["hephaestus.*"] -exclude = ["hephaestus.tests*", "hephaestus*.__pycache__*", "hephaestus._internal*"] +exclude = ["hephaestus.tests*", "hephaestus*.__pycache__*"] namespaces = true + +[tool.black] +extend-exclude = '^.*\.(md|txt)' +include = '\.pyi?$|scripts/.*' diff --git a/scripts/generate_documentation b/scripts/generate_documentation index ab1f8e9..560c33b 100755 --- a/scripts/generate_documentation +++ b/scripts/generate_documentation @@ -11,8 +11,8 @@ from pathlib import Path sys.path.append(str(Path(__file__).parents[1])) from hephaestus._internal.meta import Paths from hephaestus.io.file import create_directory +from hephaestus.io.logging import get_logger, configure_root_logger from hephaestus.io.subprocess import run_command, command_successful -from hephaestus.util.logging import get_logger, configure_root_logger # Constants VERSION = "1.0.0" @@ -28,12 +28,12 @@ def generate_rst_files(): cmd = [ "sphinx-apidoc", str(Paths.LIB), + "**/tests/**", "--output-dir", str(Path(Paths.SPHINX, "source")), + "--implicit-namespaces", # Who wants to stick __init__.py everywhere., "--module-first", # Generate module documentation before generating submodule docs. - "--force", # Overwrite existing rst files. "--separate", # Generate a file for each module - "--implicit-namespaces", # Who wants to stick __init__.py everywhere. ] run_command(cmd, err="Failed to create ReStructured Text files.") diff --git a/scripts/run_pytest b/scripts/run_pytest index 4683e3d..9f5d37c 100755 --- a/scripts/run_pytest +++ b/scripts/run_pytest @@ -9,16 +9,16 @@ from pathlib import Path sys.path.append(str(Path(__file__).parents[1])) from hephaestus._internal.meta import Paths -from hephaestus.util.logging import get_logger, configure_root_logger +from hephaestus.io.logging import get_logger, configure_root_logger # Constants -VERSION = "1.0.0" +VERSION = "1.0.1" LOG_FILE = Path(Paths.LOGS, "PyTest.log") fail = lambda: exit(1) logger = get_logger(root=Paths.ROOT) -# We're running PyTest like regular Python functions here so we can hijack the config options +# We're running PyTest like a regular Python method here so we can hijack the config options # for the script. This means we'll have to do some late imports which I don't particularly like, # but it's a small tradeoff.