diff --git a/pyproject.toml b/pyproject.toml index 591b793..659c56e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "zenlib" -version = "2.4.1" +version = "3.0.0" authors = [ { name="Desultory", email="dev@pyl.onl" }, ] diff --git a/readme.md b/readme.md index 6a094ad..8c031fd 100644 --- a/readme.md +++ b/readme.md @@ -142,33 +142,3 @@ If `key` is a dict, the structure will be used to walk the `validate_dict`. * `message` Set the vailidation failure message. Additional arguments exist to set a value to compare found keys against - -## Threading - -> This is not being maintained, please don't use it, it's just here because I used to use it. - -### ZenThread - -An extension of the builtin thread that sets results to self.return_value and exceptions to self.exception. - -Supports re-starting and looping with the self.loop event. - -### @threaded - -Runs the wrapped function in a thread when called. - -Adds the thread to `_threads` within the object. - -If an exception is raised, it will be added to `self._threads` in the form `(thread, exception_queue)`. - -### @thread_wrapped('threadname') - -Meant to be used with `@add_thread`, the argument is the threadname that function is associated with. - -### @add_thread('threadname', 'target_function', 'description') - -`@add_thread` decorates a class, and adds `create_{threadname}_thread`, `start_` and `stop_` functions which are used to handle thread management of a `thread_wrapped` function. - -Once added, a thread will be added to `self.threads[threadname]`. - - diff --git a/src/zenlib/__init__.py b/src/zenlib/__init__.py index 5b8d555..a6e6d1b 100644 --- a/src/zenlib/__init__.py +++ b/src/zenlib/__init__.py @@ -1,11 +1,13 @@ from .logging import ColorLognameFormatter, loggify -from .util import NoDupFlatList, check_dict, handle_plural, pretty_print, replace_file_line, update_init, walk_dict +from .types import NoDupFlatList, validatedDataclass +from .util import check_dict, handle_plural, pretty_print, replace_file_line, update_init, walk_dict __all__ = [ "ColorLognameFormatter", "loggify", "handle_plural", "NoDupFlatList", + "validatedDataclass", "pretty_print", "replace_file_line", "update_init", diff --git a/src/zenlib/logging/classlogger.py b/src/zenlib/logging/classlogger.py index 6a80dc5..dcf39d1 100644 --- a/src/zenlib/logging/classlogger.py +++ b/src/zenlib/logging/classlogger.py @@ -1,7 +1,7 @@ __author__ = "desultory" -__version__ = "2.1.0" +__version__ = "2.2.0" -from .utils import add_handler_if_not_exists, log_init, log_setattr +from .utils import add_handler_if_not_exists, log_init, handle_additional_logging from logging import Logger, getLogger @@ -21,14 +21,8 @@ def __init__(self, *args, **kwargs): # Log class init if _log_init is passed log_init(self, args, kwargs) - # add setattr logging - setattr(self, "__setattr__", log_setattr) + # Add logging to _log_setattr if set + handle_additional_logging(self, kwargs) if super().__class__.__class__ is not type: super().__init__(*args, **kwargs) - - def __setitem__(self, name, value): - """ Add logging to dict setitem. """ - if hasattr(super(), '__setitem__'): - super().__setitem__(name, value) - self.logger.log(5, "Setitem '%s' to: %s" % (name, value)) diff --git a/src/zenlib/logging/loggify.py b/src/zenlib/logging/loggify.py index aa639a8..f52215a 100644 --- a/src/zenlib/logging/loggify.py +++ b/src/zenlib/logging/loggify.py @@ -1,20 +1,22 @@ __author__ = "desultory" -__version__ = "2.4.0" - -from .utils import add_handler_if_not_exists, log_init, log_setattr +__version__ = "2.4.2" from logging import Logger, getLogger +from zenlib.util import merge_class + +from .utils import add_handler_if_not_exists, log_init, handle_additional_logging + def loggify(cls): class ClassLogger(cls): def __init__(self, *args, **kwargs): # Get the parent logger from the root if one was not passed - parent_logger = kwargs.pop('logger') if isinstance(kwargs.get('logger'), Logger) else getLogger() + parent_logger = kwargs.pop("logger") if isinstance(kwargs.get("logger"), Logger) else getLogger() # Get a child logger from the parent logger, set self.logger self.logger = parent_logger.getChild(cls.__name__) # Bump the log level if _log_bump is passed - self.logger.setLevel(self.logger.parent.level + kwargs.pop('_log_bump', 0)) + self.logger.setLevel(self.logger.parent.level + kwargs.pop("_log_bump", 0)) # Add a colored stream handler if one does not exist add_handler_if_not_exists(self.logger) @@ -22,13 +24,10 @@ def __init__(self, *args, **kwargs): # Log class init if _log_init is passed log_init(self, args, kwargs) - # Add setattr logging - setattr(self, "__setattr__", log_setattr) + # Add logging to _log_setattr if set + handle_additional_logging(self, kwargs) super().__init__(*args, **kwargs) - ClassLogger.__name__ = cls.__name__ - ClassLogger.__module__ = cls.__module__ - ClassLogger.__doc__ = cls.__doc__ - ClassLogger.__qualname__ = cls.__qualname__ + merge_class(cls, ClassLogger, ignored_attributes=["__setattr__"]) return ClassLogger diff --git a/src/zenlib/logging/utils.py b/src/zenlib/logging/utils.py index db9f416..1796578 100644 --- a/src/zenlib/logging/utils.py +++ b/src/zenlib/logging/utils.py @@ -25,7 +25,7 @@ def log_init(self, args, kwargs): class_name = self.__class__.__name__ logger = self.logger if not kwargs.pop("_log_init", False): - return logger.debug("Init logging disabled for class: %s", class_name) + return logger.log(5, "Init logging disabled for class: %s", class_name) logger.info("Initializing class: %s", class_name) @@ -49,6 +49,11 @@ def log_init(self, args, kwargs): if class_version := getattr(self, '__version__', None): logger.info("[%s] Class version: %s" % (class_name, class_version)) +def handle_additional_logging(self, kwargs): + """ Sets __setattr__ to log_setattr if _log_setattr is in the kwargs and set to True """ + if kwargs.pop("_log_setattr", False): + setattr(self, "__setattr__", log_setattr) + def log_setattr(self, name, value): """ Logs when an attribute is set """ super().__setattr__(name, value) diff --git a/src/zenlib/threading/__init__.py b/src/zenlib/threading/__init__.py deleted file mode 100644 index 11136a4..0000000 --- a/src/zenlib/threading/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from .add_thread import add_thread -from .threaded import threaded -from .zenthread import ZenThread -from .loop_thread import loop_thread - -__all__ = ['add_thread', 'threaded', 'ZenThread', 'loop_thread'] - - diff --git a/src/zenlib/threading/add_thread.py b/src/zenlib/threading/add_thread.py deleted file mode 100644 index 618cf9e..0000000 --- a/src/zenlib/threading/add_thread.py +++ /dev/null @@ -1,85 +0,0 @@ -__author__ = "desultory" -__version__ = "2.0.0" - -from .zenthread import ZenThread -from threading import Event -from zenlib.util import update_init - - -def add_thread(name, target, description=None): - """ - Adds a thread to a class instance. - The target is the function that the thread will run. - The description is passed to the thread as the name. - Creates a dictionary of threads in the class instance called threads. - The key is the name of the thread. - The value is the thread object. - """ - def decorator(cls): - def create_thread(self): - """ - This method reads the target from the decorator. - It is added to the class as f'create_{name}_thread'. - """ - # If the tharget has a dot in it, it is a path to a function - if "." in target: - target_parts = target.split(".") - target_attr = self - for part in target_parts: - target_attr = getattr(target_attr, part) - else: - target_attr = getattr(self, target) - - self.threads[name] = ZenThread(target=target_attr, name=description, - owner=self, logger=self.logger) - - def start_thread(self): - thread = self.threads[name] - if thread._is_stopped: - self.logger.info("Re-creating thread") - getattr(self, f"create_{name}_thread")() - thread = self.threads[name] - - if thread._started.is_set() and not thread._is_stopped: - self.logger.warning("Thread is already started: %s" % name) - else: - getattr(self, f"_running_{name}").set() - thread.start() - return thread - - def stop_thread(self, force=False): - thread = self.threads[name] - dont_join = False - if not thread._started.is_set() or thread._is_stopped: - self.logger.warning("Thread is not active: %s" % name) - dont_join = True - - if hasattr(self, f"_running_{name}"): - self.logger.debug("Clearing running event for thread: %s" % name) - getattr(self, f"_running_{name}").clear() - - if hasattr(self, f"stop_{name}_thread_actions"): - self.logger.info("Calling: %s" % f"stop_{name}_thread_actions") - getattr(self, f"stop_{name}_thread_actions")() - - if hasattr(self, f"_{name}_timer"): - self.logger.info("Stopping the timer for thread: %s" % name) - getattr(self, f"_{name}_timer").cancel() - - if force: - self.logger.info("Stopping thread: %s" % name) - thread.stop() - - if not dont_join: - self.logger.info("Waiting on thread to end: %s" % name) - thread.join() - - setattr(cls, f"create_{name}_thread", create_thread) - setattr(cls, f"start_{name}_thread", start_thread) - setattr(cls, f"stop_{name}_thread", stop_thread) - setattr(cls, f"_running_{name}", Event()) - cls.threads = {} - - # Update the __init__ method of the class - return update_init(create_thread)(cls) - return decorator diff --git a/src/zenlib/threading/loop_thread.py b/src/zenlib/threading/loop_thread.py deleted file mode 100644 index 6725999..0000000 --- a/src/zenlib/threading/loop_thread.py +++ /dev/null @@ -1,14 +0,0 @@ -__author__ = "desultory" -__version__ = "1.0.0" - - -def loop_thread(function): - """ - Wrapper for a method already wrapped with add_thread. - Causes the method to loop until the _running_{name} event is cleared. - """ - def loop_wrapper(self, *args, **kwargs): - while getattr(self, f"_running_{function.__name__}").is_set(): - function(self, *args, **kwargs) - self.logger.info("Thread received stop signal: %s" % function.__name__) - return loop_wrapper diff --git a/src/zenlib/threading/threaded.py b/src/zenlib/threading/threaded.py deleted file mode 100644 index 4c64a1b..0000000 --- a/src/zenlib/threading/threaded.py +++ /dev/null @@ -1,32 +0,0 @@ -__author__ = 'desultory' -__version__ = '2.0.0' - -from threading import Thread -from queue import Queue - - -def threaded(function): - """ - Simply starts a function in a thread - Adds it to an internal _threads list for handling - """ - def wrapper(self, *args, **kwargs): - if not hasattr(self, '_threads'): - self._threads = list() - - thread_exception = Queue() - thread_return = Queue() - - def exception_wrapper(*args, **kwargs): - try: - thread_return.put(function(*args, **kwargs)) - except Exception as e: - self.logger.warning("Exception in thread: %s" % function.__name__) - thread_exception.put(e) - self.logger.exception(e) - - thread = Thread(target=exception_wrapper, args=(self, *args), kwargs=kwargs, name=function.__name__) - self._threads.append((thread, thread_exception)) - thread.start() - return thread_return.get() - return wrapper diff --git a/src/zenlib/threading/zenthread.py b/src/zenlib/threading/zenthread.py deleted file mode 100644 index 1506a63..0000000 --- a/src/zenlib/threading/zenthread.py +++ /dev/null @@ -1,44 +0,0 @@ -__author__ = 'desultory' -__version__ = '1.0.0' - -from threading import Thread, Event -from zenlib.logging import ClassLogger - - -class ZenThread(ClassLogger, Thread): - """ A thread that stores the exception and return value of the function it runs. """ - def __init__(self, looping=False, *args, **kwargs): - super().__init__(*args, **kwargs) - self.exception = None - self.return_value = None - self.loop = Event() - self._looping = looping - - def start(self): - """ Starts the thread, sets the loop event if self._looping is True. """ - if self._looping: - self.loop.set() - self.logger.info("Starting thread: %s", self.name) - super().start() - - def run(self): - """ - Runs the thread and stores the exception and return value. - Clears the started flag when finished, does not delete the target. - """ - if not self._started.is_set(): - raise RuntimeError("Cannot run thread that has not been started: %s", self.name) - - try: - while True: - self.return_value = self._target(*self._args, **self._kwargs) - if not self.loop.is_set(): - break - except Exception as e: - self.exception = e - self.logger.error("[%s] Thread args: %s" % (self._target.__name__, self._args)) - self.logger.error("[%s] Thread kwargs: %s" % (self._target.__name__, self._kwargs)) - self.logger.exception(e) - self.logger.info("Thread finished: %s", self.name) - self._started.clear() - diff --git a/src/zenlib/types/__init__.py b/src/zenlib/types/__init__.py new file mode 100644 index 0000000..8669ce4 --- /dev/null +++ b/src/zenlib/types/__init__.py @@ -0,0 +1,4 @@ +from .nodupflatlist import NoDupFlatList +from .validated_dataclass import validatedDataclass + +__all__ = ["NoDupFlatList", "validatedDataclass"] diff --git a/src/zenlib/util/nodupflatlist.py b/src/zenlib/types/nodupflatlist.py similarity index 72% rename from src/zenlib/util/nodupflatlist.py rename to src/zenlib/types/nodupflatlist.py index 70d62ca..49d1d6c 100644 --- a/src/zenlib/util/nodupflatlist.py +++ b/src/zenlib/types/nodupflatlist.py @@ -1,19 +1,16 @@ __author__ = "desultory" -__version__ = "1.0.0" +__version__ = "1.1.1" -from zenlib.logging import loggify +from zenlib.logging import ClassLogger +from zenlib.util import handle_plural -from .handle_plural import handle_plural - -@loggify -class NoDupFlatList(list): +class NoDupFlatList(ClassLogger, list): """List that automatically filters duplicate elements when appended and concatenated.""" - - def __init__(self, no_warn=False, log_bump=0, *args, **kwargs): + def __init__(self, no_warn=False, *args, **kwargs): + super().__init__(*args, **kwargs) self.no_warn = no_warn - self.logger.setLevel(self.logger.parent.level + log_bump) @handle_plural def append(self, item): diff --git a/src/zenlib/types/validated_dataclass.py b/src/zenlib/types/validated_dataclass.py new file mode 100644 index 0000000..c0ff115 --- /dev/null +++ b/src/zenlib/types/validated_dataclass.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass +from typing import get_type_hints + +from zenlib.util import merge_class +from zenlib.logging import loggify + + +def validatedDataclass(cls): + cls = loggify(dataclass(cls)) + + class ValidatedDataclass(cls): + def __setattr__(self, attribute, value): + value = self._validate_attribute(attribute, value) + super().__setattr__(attribute, value) + + def _validate_attribute(self, attribute, value): + """Ensures the attribute is the correct type""" + if attribute == "logger": + return value + if value is None: + return + + expected_type = get_type_hints(self.__class__)[attribute] + if not isinstance(value, expected_type): + try: + value = expected_type(value) + except ValueError: + raise TypeError(f"[{attribute}] Type mismatch: '{expected_type}' != {type(value)}") + return value + + merge_class(cls, ValidatedDataclass, ignored_attributes = ["__setattr__"]) + return ValidatedDataclass diff --git a/src/zenlib/util/__init__.py b/src/zenlib/util/__init__.py index 38d8d8d..35961a0 100644 --- a/src/zenlib/util/__init__.py +++ b/src/zenlib/util/__init__.py @@ -1,7 +1,7 @@ from .dict_check import contains, unset from .handle_plural import handle_plural from .main_funcs import get_args_n_logger, get_kwargs, get_kwargs_from_args, init_argparser, init_logger, process_args -from .nodupflatlist import NoDupFlatList +from .merge_class import merge_class from .pretty_print import pretty_print from .replace_file_line import replace_file_line @@ -18,4 +18,5 @@ "get_kwargs", "contains", "unset", + "merge_class", ] diff --git a/src/zenlib/util/merge_class.py b/src/zenlib/util/merge_class.py new file mode 100644 index 0000000..ab473d8 --- /dev/null +++ b/src/zenlib/util/merge_class.py @@ -0,0 +1,31 @@ +__author__ = "desultory" +__version__ = "1.0.0" + + +def merge_class(base_class, new_class, ignored_attributes=None): + """Merges base attributes back into a new class, ignoring ignored_attributes + Intended to be used when decorating classes, so the new class has attributes such as: + __doc__ + __module__ + __annotations__ + __module__ + __name__ + __qualname__ + """ + ignored_base = ["__dict__", "__init__"] + ignored_attributes = ignored_attributes or [] + base_attributes = [a for a in base_class.__dict__ if a.startswith("__") and a.endswith("__") and a not in ignored_base] + base_attributes += ["__name__", "__qualname__", "__module__"] + for attr in base_attributes: + if attr in ignored_attributes: + continue + base_value = getattr(base_class, attr) + new_value = getattr(new_class, attr, None) + if attr in ["__init_subclass__", "__subclasshook__"]: + bv = base_value() if callable(base_value) else base_value + nv = new_value() if callable(new_value) else new_value + if bv == nv: + continue + + if base_value != new_value: + setattr(new_class, attr, base_value) diff --git a/tests/test_nodupflatlist.py b/tests/test_nodupflatlist.py index ac8808b..2e954b3 100644 --- a/tests/test_nodupflatlist.py +++ b/tests/test_nodupflatlist.py @@ -1,6 +1,6 @@ from unittest import TestCase, main -from zenlib.util import NoDupFlatList +from zenlib.types import NoDupFlatList class TestNoDupFlatList(TestCase): diff --git a/tests/test_validated_dataclass.py b/tests/test_validated_dataclass.py new file mode 100644 index 0000000..76d739f --- /dev/null +++ b/tests/test_validated_dataclass.py @@ -0,0 +1,26 @@ +from unittest import TestCase, main + +from zenlib.types import validatedDataclass + + +@validatedDataclass +class testDataClass: + a: int = None + b: str = None + + +class TestValidatedDataclass(TestCase): + def test_validated_dataclass(self): + c = testDataClass() + c.a = 1 + c.b = "test" + self.assertTrue(hasattr(c, "logger")) + + def test_bad_type(self): + c = testDataClass() + with self.assertRaises(TypeError): + c.a = "test" + + +if __name__ == "__main__": + main()