From c9fd9257dae526b44b963f54ea6ca83185a47177 Mon Sep 17 00:00:00 2001 From: Guillaume Pelletier Date: Sun, 14 May 2023 17:58:09 -0700 Subject: [PATCH 1/5] WIP --- pybond/james.py | 44 +++++++++++++++++++++++++++++++++++++++----- pybond/util.py | 16 ++++++++++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/pybond/james.py b/pybond/james.py index ba31aed..8d3a72f 100644 --- a/pybond/james.py +++ b/pybond/james.py @@ -10,7 +10,12 @@ from pytest import MonkeyPatch from pybond.memory import replace_bound_references_in_memory -from pybond.util import function_signatures_match, is_wrapped_function +from pybond.util import ( + function_signatures_match, + is_wrapped_function, + list_class_attributes, + list_class_methods, +) from pybond.types import FunctionCall, Spyable, SpyTarget, StubTarget @@ -65,6 +70,13 @@ def handle_function_call(*args, **kwargs): return handle_function_call +def _spy_all_methods(obj: object) -> object: + obj_methods = list_class_methods(obj) + for method_name in obj_methods: + setattr(obj, method_name, _spy_function(getattr(obj, method_name))) + return obj + + def calls(f: Spyable) -> list[FunctionCall]: """ Takes one arg, a function that has previously been spied. Returns a list of @@ -103,8 +115,31 @@ def _check_if_class_is_instrumentable( stub_obj: Spyable, strict: bool = True, ) -> None: - # TODO: implement spying on classes and class methods - return + original_obj_attributes = list_class_attributes(original_obj) + original_obj_methods = list_class_methods(original_obj) + stub_obj_attributes = list_class_attributes(stub_obj) + stub_obj_methods = list_class_methods(stub_obj) + if strict: + if set(original_obj_attributes) != set(stub_obj_attributes): + raise ValueError( + "Stub does not have the same set of attributes as " + f"{original_obj.__class__.__name__}." + ) + if set(original_obj_methods) != set(stub_obj_methods): + raise ValueError( + "Stub does not have the same set of methods as " + f"{original_obj.__class__.__name__}." + ) + for method_name in original_obj_methods: + if not _function_signatures_match( + getattr(original_obj, method_name), + getattr(stub_obj, method_name), + ): + raise ValueError( + f"Stub method {stub_obj.__class__.__name__}.{method_name} " + "does not match the signature of " + f"{original_obj.__class__.__name__}.{method_name}." + ) def _check_if_function_is_instrumentable( @@ -124,9 +159,8 @@ def _instrumented_obj( strict: bool = True, ) -> Spyable: if isclass(original_obj): - # TODO: implement spying on classes and class methods _check_if_class_is_instrumentable(original_obj, stub_obj, strict) - return stub_obj + return _spy_all_methods(stub_obj) elif callable(original_obj) and callable(stub_obj): _check_if_function_is_instrumentable(original_obj, stub_obj, strict) return _spy_function(stub_obj) diff --git a/pybond/util.py b/pybond/util.py index 3b223d1..5685f54 100644 --- a/pybond/util.py +++ b/pybond/util.py @@ -65,3 +65,19 @@ def function_signatures_match(f, g): def is_wrapped_function(f: Callable) -> bool: return hasattr(f, "__wrapped__") + + +def list_class_attributes(obj: object) -> list[str]: + return [ + attr for attr in dir(obj) + if not callable(getattr(obj, attr)) + and not (attr.startswith("__") and attr.endswith("__")) + ] + + +def list_class_methods(obj: object) -> list[str]: + return [ + attr for attr in dir(obj) + if callable(getattr(obj, attr)) + and not (attr.startswith("__") and attr.endswith("__")) + ] From 895e37f76eea709f6ce428529aa912d115d819fe Mon Sep 17 00:00:00 2001 From: Guillaume Pelletier Date: Sun, 14 May 2023 19:34:40 -0700 Subject: [PATCH 2/5] F --- pybond/james.py | 93 ++++++++++++++++++++++++++++---------- pybond/types.py | 4 +- pybond/util.py | 4 +- tests/sample_code/mocks.py | 22 +++++++-- 4 files changed, 95 insertions(+), 28 deletions(-) diff --git a/pybond/james.py b/pybond/james.py index 8d3a72f..918786c 100644 --- a/pybond/james.py +++ b/pybond/james.py @@ -1,11 +1,11 @@ """This module is inspired by clojure's bond library.""" -import sys +import warnings from contextlib import contextmanager from copy import deepcopy from functools import wraps from inspect import isclass -from typing import Callable +from sys import exc_info from pytest import MonkeyPatch @@ -16,7 +16,14 @@ list_class_attributes, list_class_methods, ) -from pybond.types import FunctionCall, Spyable, SpyTarget, StubTarget +from pybond.types import ( + FunctionCall, + Spyable, + SpyableClass, + SpyableFunction, + SpyTarget, + StubTarget, +) def _function_call(args, kwargs, error, return_value) -> FunctionCall: @@ -28,7 +35,7 @@ def _function_call(args, kwargs, error, return_value) -> FunctionCall: } -def _spy_function(f: Callable) -> Spyable: +def _spy_function(f: SpyableFunction) -> Spyable: """ Wrap f, returning a new function that keeps track of its call count and arguments. @@ -59,7 +66,7 @@ def handle_function_call(*args, **kwargs): _function_call( args=non_mutated_args, kwargs=non_mutated_kwargs, - error=sys.exc_info(), + error=exc_info(), return_value=None, ) ) @@ -70,7 +77,7 @@ def handle_function_call(*args, **kwargs): return handle_function_call -def _spy_all_methods(obj: object) -> object: +def _spy_all_methods(obj: SpyableClass) -> SpyableClass: obj_methods = list_class_methods(obj) for method_name in obj_methods: setattr(obj, method_name, _spy_function(getattr(obj, method_name))) @@ -94,7 +101,10 @@ def calls(f: Spyable) -> list[FunctionCall]: ) -def _function_signatures_match(originalf: Callable, stubf: Callable) -> bool: +def _function_signatures_match( + originalf: SpyableFunction, + stubf: SpyableFunction, +) -> bool: """ Supports both regular functions and decorated functions using functools.wraps @@ -110,6 +120,38 @@ def _function_signatures_match(originalf: Callable, stubf: Callable) -> bool: ) +def _check_if_class_methods_are_instrumentable( + method_names: list[str], + original_obj: Spyable, + stub_obj: Spyable, +) -> None: + unsupported_callables = [] + for method_name in method_names: + try: + if not _function_signatures_match( + getattr(original_obj, method_name), + getattr(stub_obj, method_name), + ): + raise ValueError( + f"Stub method {stub_obj.__name__}.{method_name} does not " + "match the signature of the original " + f"{original_obj.__name__}.{method_name} class method. " + "Please ensure the implementation of the provided stub " + "matches that of the original class, or set the 'strict' " + "option to False." + ) + except TypeError as e: + if str(e) == "unsupported callable": + unsupported_callables.append(method_name) + + if len(unsupported_callables) > 0: + PYBOND_WARNING__unsupported_callables = ( + "The following methods' signatures cannot be checked: " + f"{unsupported_callables}." + ) + warnings.warn(PYBOND_WARNING__unsupported_callables) + + def _check_if_class_is_instrumentable( original_obj: Spyable, stub_obj: Spyable, @@ -122,29 +164,34 @@ def _check_if_class_is_instrumentable( if strict: if set(original_obj_attributes) != set(stub_obj_attributes): raise ValueError( - "Stub does not have the same set of attributes as " - f"{original_obj.__class__.__name__}." + f"Stub object '{stub_obj.__name__}' does not have the same set " + f"of attributes as the original '{original_obj.__name__}' " + "class. Please ensure the implementation of the provided stub " + "matches that of the original class, or set the 'strict' " + "option to False.\n" + f"Original: {original_obj_attributes}\n" + f"Provided: {stub_obj_attributes}" ) if set(original_obj_methods) != set(stub_obj_methods): raise ValueError( - "Stub does not have the same set of methods as " - f"{original_obj.__class__.__name__}." + f"Stub object '{stub_obj.__name__}' does not have the same set " + f"of methods as the original '{original_obj.__name__}' class. " + "Please ensure the implementation of the provided stub matches " + "that of the original class, or set the 'strict' option to " + "False.\n" + f"Original: {original_obj_methods}\n" + f"Provided: {stub_obj_methods}" ) - for method_name in original_obj_methods: - if not _function_signatures_match( - getattr(original_obj, method_name), - getattr(stub_obj, method_name), - ): - raise ValueError( - f"Stub method {stub_obj.__class__.__name__}.{method_name} " - "does not match the signature of " - f"{original_obj.__class__.__name__}.{method_name}." - ) + _check_if_class_methods_are_instrumentable( + method_names=original_obj_methods, + original_obj=original_obj, + stub_obj=stub_obj, + ) def _check_if_function_is_instrumentable( - original_obj: Callable, - stub_obj: Callable, + original_obj: SpyableFunction, + stub_obj: SpyableFunction, strict: bool = True, ) -> None: if strict and not _function_signatures_match(original_obj, stub_obj): diff --git a/pybond/types.py b/pybond/types.py index 7553ebf..abb49b0 100644 --- a/pybond/types.py +++ b/pybond/types.py @@ -11,6 +11,8 @@ } ) -Spyable: TypeAlias = Callable | object +SpyableClass: TypeAlias = Any +SpyableFunction: TypeAlias = Callable +Spyable: TypeAlias = SpyableFunction | SpyableClass SpyTarget: TypeAlias = Tuple[ModuleType, str] StubTarget: TypeAlias = Tuple[ModuleType, str, Spyable] diff --git a/pybond/util.py b/pybond/util.py index 5685f54..ef573e3 100644 --- a/pybond/util.py +++ b/pybond/util.py @@ -54,7 +54,9 @@ def function_signatures_match(f, g): # Python. For example, in CPython, some built-in functions defined in C # provide no metadata about their arguments. if str(e) == "unsupported callable": - if [f.__module__, f.__name__] == ["time", "time"]: + fmodule = getattr(f, "__module__", None) + fname = getattr(f, "__name__", None) + if [fmodule, fname] == ["time", "time"]: return function_signatures_match(_fn_with_zero_arguments, g) # Add other specific cases here else: diff --git a/tests/sample_code/mocks.py b/tests/sample_code/mocks.py index abdca46..f8b9175 100644 --- a/tests/sample_code/mocks.py +++ b/tests/sample_code/mocks.py @@ -1,3 +1,6 @@ +from datetime import datetime + + def mock_write_to_disk(x): return "Wrote to disk!" @@ -14,9 +17,22 @@ def mock_make_a_network_request( def create_mock_datetime(mock_now): - class MockDatetime(): - @staticmethod - def now(tz=None): + class MockDatetime(datetime): + day = mock_now.day + fold = mock_now.fold + hour = mock_now.hour + max = mock_now.max + microsecond = mock_now.microsecond + min = mock_now.min + minute = mock_now.minute + month = mock_now.month + resolution = mock_now.resolution + second = mock_now.second + tzinfo = mock_now.tzinfo + year = mock_now.year + + @classmethod + def now(cls, tz=None): return mock_now return MockDatetime From c0cae7fdb4fe369f37e27c52e24f2e2f5173f515 Mon Sep 17 00:00:00 2001 From: Guillaume Pelletier Date: Sun, 14 May 2023 19:36:16 -0700 Subject: [PATCH 3/5] F --- tests/sample_code/mocks.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/tests/sample_code/mocks.py b/tests/sample_code/mocks.py index f8b9175..228abef 100644 --- a/tests/sample_code/mocks.py +++ b/tests/sample_code/mocks.py @@ -18,18 +18,6 @@ def mock_make_a_network_request( def create_mock_datetime(mock_now): class MockDatetime(datetime): - day = mock_now.day - fold = mock_now.fold - hour = mock_now.hour - max = mock_now.max - microsecond = mock_now.microsecond - min = mock_now.min - minute = mock_now.minute - month = mock_now.month - resolution = mock_now.resolution - second = mock_now.second - tzinfo = mock_now.tzinfo - year = mock_now.year @classmethod def now(cls, tz=None): From a478810be9a93878f07b5e4b0c45a78011b85557 Mon Sep 17 00:00:00 2001 From: Guillaume Pelletier Date: Sun, 14 May 2023 19:36:39 -0700 Subject: [PATCH 4/5] F --- tests/sample_code/mocks.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/sample_code/mocks.py b/tests/sample_code/mocks.py index 228abef..61ca802 100644 --- a/tests/sample_code/mocks.py +++ b/tests/sample_code/mocks.py @@ -18,7 +18,6 @@ def mock_make_a_network_request( def create_mock_datetime(mock_now): class MockDatetime(datetime): - @classmethod def now(cls, tz=None): return mock_now From 4d7fa667dd2e0ca24fc34ea058290d5ff49cbe6f Mon Sep 17 00:00:00 2001 From: Guillaume Pelletier Date: Mon, 15 May 2023 09:32:08 -0700 Subject: [PATCH 5/5] WIP --- pybond/checks.py | 108 ++++++++++++++++++++++++++++++++++++++ pybond/james.py | 132 +++++++---------------------------------------- 2 files changed, 126 insertions(+), 114 deletions(-) create mode 100644 pybond/checks.py diff --git a/pybond/checks.py b/pybond/checks.py new file mode 100644 index 0000000..96edca1 --- /dev/null +++ b/pybond/checks.py @@ -0,0 +1,108 @@ +import warnings + +from pybond.types import SpyableClass, SpyableFunction +from pybond.util import ( + function_signatures_match, + is_wrapped_function, + list_class_attributes, + list_class_methods, +) + + +def _function_signatures_match( + originalf: SpyableFunction, + stubf: SpyableFunction, +) -> bool: + """ + Supports both regular functions and decorated functions using + functools.wraps + """ + return ( + ( + is_wrapped_function(originalf) + and function_signatures_match(originalf.__wrapped__, stubf) + ) or ( + not is_wrapped_function(originalf) + and function_signatures_match(originalf, stubf) + ) + ) + + +def check_if_function_is_instrumentable( + original_obj: SpyableFunction, + stub_obj: SpyableFunction, + strict: bool = True, +) -> None: + if strict and not _function_signatures_match(original_obj, stub_obj): + raise ValueError( + f"Stub does not match the signature of {original_obj.__name__}." + ) + + +def _check_if_class_methods_are_instrumentable( + method_names: list[str], + original_obj: SpyableClass, + stub_obj: SpyableClass, +) -> None: + unsupported_callables = [] + for method_name in method_names: + try: + if not _function_signatures_match( + getattr(original_obj, method_name), + getattr(stub_obj, method_name), + ): + raise ValueError( + f"Stub method {stub_obj.__name__}.{method_name} does not " + "match the signature of the original " + f"{original_obj.__name__}.{method_name} class method. " + "Please ensure the implementation of the provided stub " + "matches that of the original class, or set the 'strict' " + "option to False." + ) + except TypeError as e: + if str(e) == "unsupported callable": + unsupported_callables.append(method_name) + + if len(unsupported_callables) > 0: + PYBOND_WARNING__unsupported_callables = ( + "The following methods' signatures cannot be checked: " + f"{unsupported_callables}." + ) + warnings.warn(PYBOND_WARNING__unsupported_callables) + + +def check_if_class_is_instrumentable( + original_obj: SpyableClass, + stub_obj: SpyableClass, + strict: bool = True, +) -> None: + original_obj_attributes = list_class_attributes(original_obj) + original_obj_methods = list_class_methods(original_obj) + stub_obj_attributes = list_class_attributes(stub_obj) + stub_obj_methods = list_class_methods(stub_obj) + if strict: + if set(original_obj_attributes) != set(stub_obj_attributes): + raise ValueError( + f"Stub object '{stub_obj.__name__}' does not have the same set " + f"of attributes as the original '{original_obj.__name__}' " + "class. Please ensure the implementation of the provided stub " + "matches that of the original class, or set the 'strict' " + "option to False.\n" + f"Original: {original_obj_attributes}\n" + f"Provided: {stub_obj_attributes}" + ) + if set(original_obj_methods) != set(stub_obj_methods): + raise ValueError( + f"Stub object '{stub_obj.__name__}' does not have the same set " + f"of methods as the original '{original_obj.__name__}' class. " + "Please ensure the implementation of the provided stub matches " + "that of the original class, or set the 'strict' option to " + "False.\n" + f"Original: {original_obj_methods}\n" + f"Provided: {stub_obj_methods}" + ) + _check_if_class_methods_are_instrumentable( + method_names=original_obj_methods, + original_obj=original_obj, + stub_obj=stub_obj, + ) diff --git a/pybond/james.py b/pybond/james.py index 918786c..bf3c4b7 100644 --- a/pybond/james.py +++ b/pybond/james.py @@ -1,6 +1,5 @@ """This module is inspired by clojure's bond library.""" -import warnings from contextlib import contextmanager from copy import deepcopy from functools import wraps @@ -9,13 +8,11 @@ from pytest import MonkeyPatch -from pybond.memory import replace_bound_references_in_memory -from pybond.util import ( - function_signatures_match, - is_wrapped_function, - list_class_attributes, - list_class_methods, +from pybond.checks import ( + check_if_class_is_instrumentable, + check_if_function_is_instrumentable, ) +from pybond.memory import replace_bound_references_in_memory from pybond.types import ( FunctionCall, Spyable, @@ -24,6 +21,7 @@ SpyTarget, StubTarget, ) +from pybond.util import list_class_methods def _function_call(args, kwargs, error, return_value) -> FunctionCall: @@ -77,13 +75,6 @@ def handle_function_call(*args, **kwargs): return handle_function_call -def _spy_all_methods(obj: SpyableClass) -> SpyableClass: - obj_methods = list_class_methods(obj) - for method_name in obj_methods: - setattr(obj, method_name, _spy_function(getattr(obj, method_name))) - return obj - - def calls(f: Spyable) -> list[FunctionCall]: """ Takes one arg, a function that has previously been spied. Returns a list of @@ -101,103 +92,11 @@ def calls(f: Spyable) -> list[FunctionCall]: ) -def _function_signatures_match( - originalf: SpyableFunction, - stubf: SpyableFunction, -) -> bool: - """ - Supports both regular functions and decorated functions using - functools.wraps - """ - return ( - ( - is_wrapped_function(originalf) - and function_signatures_match(originalf.__wrapped__, stubf) - ) or ( - not is_wrapped_function(originalf) - and function_signatures_match(originalf, stubf) - ) - ) - - -def _check_if_class_methods_are_instrumentable( - method_names: list[str], - original_obj: Spyable, - stub_obj: Spyable, -) -> None: - unsupported_callables = [] - for method_name in method_names: - try: - if not _function_signatures_match( - getattr(original_obj, method_name), - getattr(stub_obj, method_name), - ): - raise ValueError( - f"Stub method {stub_obj.__name__}.{method_name} does not " - "match the signature of the original " - f"{original_obj.__name__}.{method_name} class method. " - "Please ensure the implementation of the provided stub " - "matches that of the original class, or set the 'strict' " - "option to False." - ) - except TypeError as e: - if str(e) == "unsupported callable": - unsupported_callables.append(method_name) - - if len(unsupported_callables) > 0: - PYBOND_WARNING__unsupported_callables = ( - "The following methods' signatures cannot be checked: " - f"{unsupported_callables}." - ) - warnings.warn(PYBOND_WARNING__unsupported_callables) - - -def _check_if_class_is_instrumentable( - original_obj: Spyable, - stub_obj: Spyable, - strict: bool = True, -) -> None: - original_obj_attributes = list_class_attributes(original_obj) - original_obj_methods = list_class_methods(original_obj) - stub_obj_attributes = list_class_attributes(stub_obj) - stub_obj_methods = list_class_methods(stub_obj) - if strict: - if set(original_obj_attributes) != set(stub_obj_attributes): - raise ValueError( - f"Stub object '{stub_obj.__name__}' does not have the same set " - f"of attributes as the original '{original_obj.__name__}' " - "class. Please ensure the implementation of the provided stub " - "matches that of the original class, or set the 'strict' " - "option to False.\n" - f"Original: {original_obj_attributes}\n" - f"Provided: {stub_obj_attributes}" - ) - if set(original_obj_methods) != set(stub_obj_methods): - raise ValueError( - f"Stub object '{stub_obj.__name__}' does not have the same set " - f"of methods as the original '{original_obj.__name__}' class. " - "Please ensure the implementation of the provided stub matches " - "that of the original class, or set the 'strict' option to " - "False.\n" - f"Original: {original_obj_methods}\n" - f"Provided: {stub_obj_methods}" - ) - _check_if_class_methods_are_instrumentable( - method_names=original_obj_methods, - original_obj=original_obj, - stub_obj=stub_obj, - ) - - -def _check_if_function_is_instrumentable( - original_obj: SpyableFunction, - stub_obj: SpyableFunction, - strict: bool = True, -) -> None: - if strict and not _function_signatures_match(original_obj, stub_obj): - raise ValueError( - f"Stub does not match the signature of {original_obj.__name__}." - ) +def _spy_all_methods(obj: SpyableClass) -> SpyableClass: + obj_methods = list_class_methods(obj) + for method_name in obj_methods: + setattr(obj, method_name, _spy_function(getattr(obj, method_name))) + return obj def _instrumented_obj( @@ -205,12 +104,17 @@ def _instrumented_obj( stub_obj: Spyable, strict: bool = True, ) -> Spyable: - if isclass(original_obj): - _check_if_class_is_instrumentable(original_obj, stub_obj, strict) + if isclass(original_obj) and isclass(stub_obj): + check_if_class_is_instrumentable(original_obj, stub_obj, strict) return _spy_all_methods(stub_obj) elif callable(original_obj) and callable(stub_obj): - _check_if_function_is_instrumentable(original_obj, stub_obj, strict) + check_if_function_is_instrumentable(original_obj, stub_obj, strict) return _spy_function(stub_obj) + elif isclass(original_obj) and not isclass(stub_obj): + raise ValueError( + f"Provided stub for class {original_obj.__name__} of type " + f"{type(stub_obj)} is invalid: pybond expected a class instance." + ) elif callable(original_obj) and not callable(stub_obj): raise ValueError( f"Provided stub for Callable {original_obj.__name__} of type "