From abab1ff395c4d223f12f6e0abbe849e07f9075a3 Mon Sep 17 00:00:00 2001 From: Chris Withers Date: Tue, 7 Feb 2023 08:14:03 +0000 Subject: [PATCH] Documentation for new replacement methods and context managers --- docs/api.txt | 6 + docs/mocking.txt | 241 +++++++++++++++++++++++++++++++++++++++- testfixtures/replace.py | 67 ++++++++++- 3 files changed, 306 insertions(+), 8 deletions(-) diff --git a/docs/api.txt b/docs/api.txt index 2635fc0..05b4dfa 100644 --- a/docs/api.txt +++ b/docs/api.txt @@ -80,6 +80,12 @@ Mocking .. autoclass:: Replace :members: +.. autofunction:: replace_in_environ + +.. autofunction:: replace_on_class + +.. autofunction:: replace_in_module + .. autoclass:: Replacer :members: :special-members: __call__ diff --git a/docs/mocking.txt b/docs/mocking.txt index 5cf0105..10034a8 100644 --- a/docs/mocking.txt +++ b/docs/mocking.txt @@ -63,8 +63,8 @@ for example, the following function: def mock_y(self): return 'mock y' -The context manager -~~~~~~~~~~~~~~~~~~~ +The context managers +~~~~~~~~~~~~~~~~~~~~ For replacement of a single thing, it's easiest to use the :class:`~testfixtures.Replace` context manager: @@ -102,6 +102,75 @@ For the duration of the ``with`` block, the replacement is used: >>> test_function() mock y +For replacements that are friendly to static analysis tools such as IDEs, three convenience +context managers are provided: + +- To replace or remove environment variables, use :func:`replace_in_environ`: + + .. code-block:: python + + import os + from testfixtures import replace_in_environ + + def test_function(): + with replace_in_environ('SOME_ENV_VAR', 1234): + print(repr(os.environ['SOME_ENV_VAR'])) + + For the duration of the ``with`` block, the replacement is used: + + >>> test_function() + '1234' + + For more details, see :ref:`replacing-in-environ`. + +- To replace methods on classes, including normal methods, class methods and static methods, + use :func:`replace_on_class`: + + .. code-block:: python + + from testfixtures import replace_on_class + + class MyClass: + + def the_method(self, value: str) -> str: + return 'original' + value + + instance = MyClass() + + def test_function(): + with replace_on_class( + MyClass.the_method, + lambda self, value: type(self).__name__+value + ): + print(instance.the_method(':it')) + + For the duration of the ``with`` block, the replacement is used: + + >>> test_function() + MyClass:it + + For more details, see :ref:`replacing-on-classes`. + +- To replace functions in modules use :func:`replace_in_module`: + + .. code-block:: python + + from testfixtures import replace_in_module + from testfixtures.tests.sample1 import z as original_z + + def test_function(): + with replace_in_module(original_z, lambda: 'replacement z'): + from testfixtures.tests.sample1 import z + print(z()) + + For the duration of the ``with`` block, the replacement is used: + + >>> test_function() + replacement z + + For more details, see :ref:`replacing-in-modules`. + + The decorator ~~~~~~~~~~~~~ @@ -333,6 +402,8 @@ When it is no longer in effect, the originals are returned: >>> pprint(some_dict) {'complex_key': [1, 2, 3], 'key': 'value'} +If the dictionary is :any:`os.environ`, then see :ref:`replacing-in-environ`. + .. _removing_attr_and_item: Removing attributes and dictionary items @@ -394,6 +465,172 @@ When it is no longer in effect, ``key`` is returned: >>> pprint(sample1.some_dict) {'complex_key': [1, 2, 3], 'key': 'value'} +.. _replacing-in-environ: + +Replacing environment variables +------------------------------- + +To ensure an environment variable is present and set to a particular value, +use :meth:`~Replacer.in_environ`: + +>>> import os +>>> replace = Replacer() +>>> replace.in_environ('SOME_ENV_VAR', 1234) +>>> print(repr(os.environ['SOME_ENV_VAR'])) +'1234' + +If you want to make sure an environment variable is unset and not present, use :any:`not_there`: + +>>> replace.in_environ('SOME_ENV_VAR', not_there) +>>> 'SOME_ENV_VAR' in os.environ +False + +.. invisible-code-block: python + + replace.restore() + +.. _replacing-on-classes: + +Replacing methods on classes +---------------------------- + +To replace methods on classes, including normal methods, class methods and static methods, +in a way that is friendly to static analysis, use :meth:`~Replacer.on_class`: + +.. code-block:: python + + class MyClass: + + def normal_method(self, value: str) -> str: + return 'original' + value + + @classmethod + def class_method(cls, value: str) -> str: + return 'original' + value + + @staticmethod + def static_method(value: str) -> str: + return 'original' + value + +For normal methods, the replacement will be called with the correct ``self``: + +>>> instance = MyClass() +>>> replace = Replacer() +>>> replace.on_class(MyClass.normal_method, lambda self, value: type(self).__name__+value) +>>> print(instance.normal_method(':it')) +MyClass:it + +For class methods, the replacement you provide will be wrapped in a :any:`classmethod` +if you have not already done so: + +>>> replace.on_class(MyClass.class_method, lambda cls, value: cls.__name__+value) +>>> print(instance.class_method(':it')) +MyClass:it + +Likewise, for static methods, the replacement you provide will be wrapped in a :any:`staticmethod` +if you have not already done so: + +>>> replace.on_class(MyClass.static_method, lambda value: 'mocked'+value) +>>> print(instance.static_method(':it')) +mocked:it + +.. invisible-code-block: python + + replace.restore() + +If you need to replace a class attribute such as ``FOO`` in this example: + +.. code-block:: python + + class MyClass: + FOO = 1 + +It can be done like this: + +>>> instance = MyClass() +>>> replace = Replacer() +>>> replace(MyClass.FOO, 42, container=MyClass, name='FOO') +42 +>>> print(instance.FOO) +42 + +.. invisible-code-block: python + + replace.restore() + +If you encounter methods that have an incorrect ``__name__``, such as those returned by poorly +implemented decorators: + +.. code-block:: python + + def bad(f): + def inner(self, x): + return f(self, x) + return inner + + class SampleClass: + + @bad + def method(self, x): + return x*2 + +They can be replaced by specifying the correct name: + +>>> instance = SampleClass() +>>> replace = Replacer() +>>> replace.on_class(SampleClass.method, lambda self, value: value*3, name='method') +>>> print(instance.method(2)) +6 + +.. invisible-code-block: python + + replace.restore() + +.. _replacing-in-modules: + +Replacing items in modules +-------------------------- + +To replace functions in modules use :meth:`~Replacer.in_module`: + +>>> from testfixtures.tests.sample1 import z as original_z +>>> replace = Replacer() +>>> replace.in_module(original_z, lambda: 'replacement z') +>>> from testfixtures.tests.sample1 import z +>>> z() +'replacement z' + +.. invisible-code-block: python + + replace.restore() + +If you need to replace usage in a module other than the one where the function is defined, +it can be done as follows + +>>> from testfixtures.tests.sample1 import z +>>> from testfixtures.tests import sample3 +>>> replace = Replacer() +>>> replace.in_module(z, lambda: 'replacement z', module=sample3) +>>> sample3.z() +'replacement z' + +.. invisible-code-block: python + + replace.restore() + +If you need to replace a module global, then you can use :class:`Replace` as follows: + +>>> from testfixtures.tests import sample3 +>>> replacer = Replacer() +>>> replacer.replace(sample3.SOME_CONSTANT, 43, +... container=sample3, name='SOME_CONSTANT') +>>> from testfixtures.tests.sample3 import SOME_CONSTANT +>>> SOME_CONSTANT +43 + +.. invisible-code-block: python + + replacer.restore() Gotchas ------- diff --git a/testfixtures/replace.py b/testfixtures/replace.py index 61054da..84540fe 100644 --- a/testfixtures/replace.py +++ b/testfixtures/replace.py @@ -128,6 +128,13 @@ def replace(self, target: Any, replacement: Any, strict: bool = True, self(target, replacement, strict, container, accessor, name) def in_environ(self, name: str, replacement: Any) -> None: + """ + This method provides a convenient way of ensuring an environment variable + in :any:`os.environ` is set to a particular value. + + If you wish to ensure that an environment variable is *not* present, + then use :any:`not_there` as the ``replacement``. + """ self(os.environ, name=name, accessor=getitem, strict=False, replacement=not_there if replacement is not_there else str(replacement)) @@ -143,6 +150,14 @@ def _find_container(self, attribute, name: str, break_on_static: bool): return None, None def on_class(self, attribute: Callable, replacement: Any, name: str = None) -> None: + """ + This method provides a convenient way to replace methods, static methods and class + methods on their classes. + + If the attribute being replaced has a ``__name__`` that differs from the attribute + name on the class, such as that returned by poorly implemented decorators, then + ``name`` must be used to provide the correct name. + """ if not callable(attribute): raise TypeError('attribute must be callable') name = name or getattr(attribute, '__name__', None) @@ -162,6 +177,14 @@ def on_class(self, attribute: Callable, replacement: Any, name: str = None) -> N self(container, name=name, accessor=getattr, replacement=replacement) def in_module(self, target: Any, replacement: Any, module: ModuleType = None) -> None: + """ + This method provides a convenient way to replace targets that are module globals, + particularly functions or other objects with a ``__name__`` attribute. + + If an object has been imported into a module other than the one where it has been + defined, then ``module`` should be used to specify the module where you would + like the replacement to occur. + """ container = module or resolve(target.__module__).found name = target.__name__ self(container, name=name, accessor=getattr, replacement=replacement) @@ -208,6 +231,9 @@ def replace( @contextmanager def replace_in_environ(name: str, replacement: Any): + """ + This context manager provides a quick way to use :meth:`Replacer.in_environ`. + """ with Replacer() as r: r.in_environ(name, replacement) yield @@ -215,6 +241,9 @@ def replace_in_environ(name: str, replacement: Any): @contextmanager def replace_on_class(attribute: Callable, replacement: Any, name: str = None): + """ + This context manager provides a quick way to use :meth:`Replacer.on_class`. + """ with Replacer() as r: r.on_class(attribute, replacement, name) yield @@ -222,6 +251,9 @@ def replace_on_class(attribute: Callable, replacement: Any, name: str = None): @contextmanager def replace_in_module(target: Any, replacement: Any, module: ModuleType = None): + """ + This context manager provides a quick way to use :meth:`Replacer.in_module`. + """ with Replacer() as r: r.in_module(target, replacement, module) yield @@ -254,17 +286,40 @@ def __exit__(self, exc_type, exc_val, exc_tb): replace_params_doc = """ -:param target: A string containing the dotted-path to the - object to be replaced. This path may specify a - module in a package, an attribute of a module, - or any attribute of something contained within - a module. +:param target: + + This must be one of the following: + + - A string containing the dotted-path to the object to be replaced, in which case it will be + resolved the the object to be replaced. + + This path may specify a module in a package, an attribute of a module, or any attribute of + something contained within a module. + + - The container of the object to be replaced, in which case ``name`` must be specified. + + - The object to be replaced, in which case ``container`` must be specified. + ``name`` must also be specified if it cannot be obtained from the ``__name__`` attribute + of the object to be replaced. :param replacement: The object to use as a replacement. :param strict: When `True`, an exception will be raised if an attempt is made to replace an object that does - not exist. + not exist or if the object that is obtained using the ``accessor`` to + access the ``name`` from the ``container`` is not identical to the ``target``. + +:param container: + The container of the object from which ``target`` can be accessed using either + :func:`getattr` or :func:`~operator.getitem`. + +:param accessor: + Either :func:`getattr` or :func:`~operator.getitem`. If not supplied, this will be inferred + preferring :func:`~operator.getitem` over :func:`getattr`. + +:param name: + The name used to access the ``target`` from the ``container`` using the ``accessor``. + If required but not specified, the ``__name__`` attribute of the ``target`` will be used. """ # add the param docs, so we only have one copy of them!