From c8124aca7fd2e73b82490b4861e8b98c33a0c59e Mon Sep 17 00:00:00 2001 From: Daniel Sissman Date: Tue, 3 Feb 2026 18:06:04 -0800 Subject: [PATCH] Runtime Decorator Added a new decorator, `@runtimer`, and library support, to collect call run time information for function, class method, and instance method calls. Incremented the `classicist` library version number to `1.0.4`. --- CHANGELOG.md | 8 + README.md | 43 +++++ source/classicist/__init__.py | 34 +++- source/classicist/decorators/__init__.py | 5 + .../decorators/runtimer/__init__.py | 165 ++++++++++++++++++ source/classicist/exceptions/__init__.py | 2 + source/classicist/version.txt | 2 +- tests/test_runtimer.py | 88 ++++++++++ 8 files changed, 343 insertions(+), 4 deletions(-) create mode 100644 source/classicist/decorators/runtimer/__init__.py create mode 100644 tests/test_runtimer.py diff --git a/CHANGELOG.md b/CHANGELOG.md index faa93b8..b9afa3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Classicist Library Change Log +## [1.0.4] - 2026-02-03 +### Added +- Added support for the new `@runtimer` decorator which can be used to gather run times +for functions, class methods and instance methods. + +### Updated +- Improved top-level import availability for the `AliasError` and `AnnotationError` classes. + ## [1.0.3] - 2026-01-30 ### Updated - Improved logging for aliasing functionality. diff --git a/README.md b/README.md index 1dc5df3..a091927 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ The Classicist library provides several useful decorators and helper methods inc * `@deprecated` – a decorator that can be used to mark functions, classes and methods as being deprecated; * `@alias` – a decorator that can be used to add aliases to classes, methods defined within classes, module-level functions, and nested functions when overriding the aliasing scope; * `@nocache` – a decorator that can be used to mark functions and methods as not being suitable for caching; + * `@runtimer` – a decorator that can be used to time function and method calls; * `shadowproof` – a metaclass that can be used to protect subclasses from class-level attributes being overwritten (or shadowed) which can otherwise negatively affect class behaviour in some cases. @@ -449,6 +450,48 @@ class Test(object): pass ``` +#### Runtimer: Function & Method Call Timing + +The `@runtimer` decorator can be used to obtain run times for function and method calls, +including the start and stop `datetime`, the `timedelta` and the duration in seconds. + +To collect timing information simply import the `runtimer` decorator from the library, +and apply it to the function, class method or instance method that you wish to time, and +after the call has been made, you can obtain the run time information from the function +or method via the `classicist` library's `runtime` helper method, which provides access +to an instance of the library's `Runtimer` class which is used to track the run time: + +```python +from classicist import runtimer, runtime, Runtimer +from datetime import datetime +from time import sleep + +@runtimer +def function_to_time(value: int) -> int: + sleep(0.01) + return value * 100 + +# Obtain a reference to the function's Runtimer (created by the @runtimer decorator) +# This reference can be obtained before or after a call to the decorated function +runtimer: Runtimer = runtime(function_to_time) +assert isinstance(runtimer, Runtimer) + +# Obtain the time before the function call for illustrative purposes (not needed in use) +started: datetime = datetime.now() + +# Call the method to perform its work, and its runtime will be gathered +assert function_to_time(2) == 200 + +# Obtain the time after the function call for illustrative purposes (not needed in use) +stopped: datetime = datetime.now() + +# Use the gathered runtime information as needed +assert runtimer.started > started +assert runtimer.duration >= 0.01 +assert runtimer.timedelta.total_seconds() >= 0.01 +assert runtimer.stopped < stopped +``` + #### ShadowProof: Attribute Shadowing Protection Metaclass The `shadowproof` metaclass can be used to protect classes and subclasses from attribute diff --git a/source/classicist/__init__.py b/source/classicist/__init__.py index 7d7f72e..5c77eca 100755 --- a/source/classicist/__init__.py +++ b/source/classicist/__init__.py @@ -1,20 +1,39 @@ -# Decorator Classes +# Decorators from classicist.decorators import ( + # @alias decorator alias, - annotate, + # @annotation decorator annotation, - annotations, + # @classproperty decorator classproperty, + # @deprecated decorator deprecated, + # @hybridmethod decorator hybridmethod, + # @nocache decorator nocache, + # @runtimer decorator + runtimer, ) # Decorator Helper Methods from classicist.decorators import ( + # @alias decorator helper methods is_aliased, aliases, + # @annotation decorator helper methods + annotate, + annotations, + # @deprecated decorator helper methods is_deprecated, + # @runtimer decorator helper methods + runtime, + has_runtimer, +) + +# Decorator Related Classes +from classicist.decorators import ( + Runtimer, ) # Meta Classes @@ -25,6 +44,8 @@ # Exception Classes from classicist.exceptions import ( + AliasError, + AnnotationError, AttributeShadowingError, ) @@ -38,13 +59,20 @@ "deprecated", "hybridmethod", "nocache", + "runtimer", # Decorator Helper Methods "is_aliased", "aliases", "is_deprecated", + "runtime", + "has_runtimer", + # Decorator Related Classes + "Runtimer", # Meta Classes "aliased", "shadowproof", # Exception Classes + "AliasError", + "AnnotationError", "AttributeShadowingError", ] diff --git a/source/classicist/decorators/__init__.py b/source/classicist/decorators/__init__.py index a37140b..dcbced1 100644 --- a/source/classicist/decorators/__init__.py +++ b/source/classicist/decorators/__init__.py @@ -4,6 +4,7 @@ from classicist.decorators.deprecated import deprecated, is_deprecated from classicist.decorators.hybridmethod import hybridmethod from classicist.decorators.nocache import nocache +from classicist.decorators.runtimer import Runtimer, runtimer, runtime, has_runtimer __all__ = [ "alias", @@ -17,4 +18,8 @@ "is_deprecated", "hybridmethod", "nocache", + "Runtimer", + "runtimer", + "runtime", + "has_runtimer", ] diff --git a/source/classicist/decorators/runtimer/__init__.py b/source/classicist/decorators/runtimer/__init__.py new file mode 100644 index 0000000..b50379d --- /dev/null +++ b/source/classicist/decorators/runtimer/__init__.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from functools import wraps +from inspect import unwrap + +from classicist.logging import logger + +logger = logger.getChild(__name__) + + +class Runtimer(object): + """The Runtimer class times and tracks the runtime of function calls.""" + + _funcobj: callable = None + _started: datetime = None + _stopped: datetime = None + + def __init__(self, function: callable): + """Supports instantiating an instance of the Runtimer class.""" + + if not callable(function): + raise TypeError("The 'function' argument must reference a callable!") + + self._funcobj = function + + def __str__(self) -> str: + """Returns a string representation of the current Runtimer instance.""" + + return f"<{self.__class__.__name__}(started: {self.started}, stopped: {self.stopped}, duration: {self.duration})>" + + def __repr__(self) -> str: + """Returns a debug string representation of the current Runtimer instance.""" + + return f"<{self.__class__.__name__}(started: {self.started}, stopped: {self.stopped}, duration: {self.duration}) @ {hex(id(self))}>" + + def reset(self) -> Runtimer: + """Supports resetting the Runtimer timing information.""" + + self._started = None + self._stopped = None + + return self + + def start(self) -> Runtimer: + """Supports starting the Runtimer timer by recording the current datetime.""" + + self._started = datetime.now() + self._stopped = None + + return self + + def stop(self) -> Runtimer: + """Supports stopping the Runtimer timer by recording the current datetime.""" + + if self._started is None: + self._started = datetime.now() + self._stopped = datetime.now() + + return self + + @property + def function(self) -> callable: + """Supports returning the Runtimer instance's associated function/method.""" + + return self._funcobj + + @property + def started(self) -> datetime: + """Supports returning the started datetime or the current time as a fallback.""" + + return self._started or datetime.now() + + @property + def stopped(self) -> datetime: + """Supports returning the stopped datetime or the current time as a fallback.""" + + return self._stopped or datetime.now() + + @property + def timedelta(self) -> timedelta: + """Supports returning the timedelta for the decorated function's call time.""" + + if isinstance(self._started, datetime) and isinstance(self._stopped, datetime): + return self._stopped - self._started + else: + return timedelta(0) + + @property + def duration(self) -> float: + """Supports returning the duration of the decorated function's call time.""" + + return self.timedelta.total_seconds() + + +def runtimer(function: callable) -> callable: + """The runtimer decorator method creates an instance of the Runtimer class for the + specified function, allowing calls to the function to be timed.""" + + if not callable(function): + raise TypeError("The 'function' argument must reference a callable!") + + logger.debug("runtimer(function: %s)", function) + + # If the function already has an associated Runtimer instance, reset it + if isinstance( + _runtimer := getattr(function, "_classicist_runtimer", None), Runtimer + ): + _runtimer.reset() + else: + # Otherwise, create a new instance and associate it with the function + _runtimer = function._classicist_runtimer = Runtimer(function) + + @wraps(function) + def wrapper(*args, **kwargs): + logger.debug( + "runtimer(function: %s).wrapper(args: %s, kwargs: %s)", + function, + args, + kwargs, + ) + + _runtimer.start() + result = function(*args, **kwargs) + _runtimer.stop() + + return result + + return wrapper + + +def runtime(function: callable) -> Runtimer | None: + """The runtime helper method can be used to obtain the Runtimer instance for the + specified function, if one is present, allowing access to the most recent function + call start and stop time stamps and call duration.""" + + if not callable(function): + raise TypeError("The 'function' argument must reference a callable!") + + function = unwrap(function) + + logger.debug("runtime(function: %s)" % (function)) + + if isinstance( + _runtimer := getattr(function, "_classicist_runtimer", None), Runtimer + ): + return _runtimer + + +def has_runtimer(function: callable) -> bool: + """The has_runtimer helper method can be used to determine if the specified function + has an associated Runtimer instance or not, returning a boolean to indicate this.""" + + if isinstance(getattr(function, "_classicist_runtimer", None), Runtimer): + return True + else: + return False + + +__all__ = [ + "Runtimer", + "runtimer", + "runtime", + "hasruntimer", +] diff --git a/source/classicist/exceptions/__init__.py b/source/classicist/exceptions/__init__.py index 86e71e3..7e83fa0 100644 --- a/source/classicist/exceptions/__init__.py +++ b/source/classicist/exceptions/__init__.py @@ -1,4 +1,5 @@ from classicist.exceptions.decorators import ( + AliasError, AnnotationError, ) @@ -7,6 +8,7 @@ ) __all__ = [ + "AliasError", "AnnotationError", "AttributeShadowingError", ] diff --git a/source/classicist/version.txt b/source/classicist/version.txt index e4c0d46..a6a3a43 100644 --- a/source/classicist/version.txt +++ b/source/classicist/version.txt @@ -1 +1 @@ -1.0.3 \ No newline at end of file +1.0.4 \ No newline at end of file diff --git a/tests/test_runtimer.py b/tests/test_runtimer.py new file mode 100644 index 0000000..9185b3b --- /dev/null +++ b/tests/test_runtimer.py @@ -0,0 +1,88 @@ +from classicist import Runtimer, runtimer, runtime, has_runtimer + +import time + + +def test_runtimer_for_function(): + """Test the runtimer for a function.""" + + @runtimer + def complex(value: int, sleep: float = 0.01) -> int: + time.sleep(sleep) + return value * 2 + + assert callable(complex) + assert complex.__name__ == "complex" + assert has_runtimer(complex) + + assert complex(value=2) == 4 + assert isinstance(timer := runtime(complex), Runtimer) + assert 0.01 <= timer.duration < 0.02 + + assert complex(value=2, sleep=0.02) == 4 + assert isinstance(timer := runtime(complex), Runtimer) + assert 0.02 <= timer.duration < 0.03 + + +def test_runtimer_for_class_method(): + """Test the runtimer for a class method.""" + + class Test(object): + @classmethod + @runtimer # Note that the @runtimer decorator *must* go before @classmethod + def complex(cls, value: int, sleep: float = 0.01) -> int: + time.sleep(sleep) + return value * 2 + + assert callable(Test.complex) + assert Test.complex.__name__ == "complex" + assert has_runtimer(Test.complex) + + assert Test.complex(value=2) == 4 + assert isinstance(timer := runtime(Test.complex), Runtimer) + assert 0.01 <= timer.duration < 0.02 + + assert Test.complex(value=2, sleep=0.02) == 4 + assert isinstance(timer := runtime(Test.complex), Runtimer) + assert 0.02 <= timer.duration < 0.03 + + +def test_runtimer_for_class_instance_method(): + """Test the runtimer for a class instance method.""" + + class Test(object): + @runtimer + def complex(self, value: int, sleep: float = 0.01) -> int: + time.sleep(sleep) + return value * 2 + + test = Test() + + assert isinstance(test, Test) + + assert callable(test.complex) + assert test.complex.__name__ == "complex" + assert has_runtimer(test.complex) + + assert test.complex(value=2) == 4 + assert isinstance(timer := runtime(test.complex), Runtimer) + assert 0.01 <= timer.duration < 0.02 + + assert test.complex(value=2, sleep=0.02) == 4 + assert isinstance(timer := runtime(test.complex), Runtimer) + assert 0.02 <= timer.duration < 0.03 + + +def test_has_runtimer_helper_method(): + """Test the has_runtimer() helper method.""" + + @runtimer + def function_with_runtimer(): + pass + + assert has_runtimer(function_with_runtimer) is True + + def function_without_runtimer(): + pass + + assert has_runtimer(function_without_runtimer) is False