From ccd2cff6f22d240c4f22bb72d25b518aa26db183 Mon Sep 17 00:00:00 2001 From: Sylvain MARIE Date: Fri, 1 Feb 2019 18:25:40 +0100 Subject: [PATCH] Added tests and updated documentation about creation from `Signature` instances --- docs/index.md | 99 ++++++++++++++++++--- makefun/tests/test_create_from_signature.py | 29 ++++++ makefun/tests/test_doc.py | 66 ++++++++++---- 3 files changed, 165 insertions(+), 29 deletions(-) create mode 100644 makefun/tests/test_create_from_signature.py diff --git a/docs/index.md b/docs/index.md index 24ac8f8..300d60a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,13 +4,20 @@ [![Build Status](https://travis-ci.org/smarie/python-makefun.svg?branch=master)](https://travis-ci.org/smarie/python-makefun) [![Tests Status](https://smarie.github.io/python-makefun/junit/junit-badge.svg?dummy=8484744)](https://smarie.github.io/python-makefun/junit/report.html) [![codecov](https://codecov.io/gh/smarie/python-makefun/branch/master/graph/badge.svg)](https://codecov.io/gh/smarie/python-makefun) [![Documentation](https://img.shields.io/badge/docs-latest-blue.svg)](https://smarie.github.io/python-makefun/) [![PyPI](https://img.shields.io/badge/PyPI-makefun-blue.svg)](https://pypi.python.org/pypi/makefun/) -This library was largely inspired by [`decorator`](https://github.com/micheles/decorator). It is currently in "experimental" state and might grow if needed later (objective would be ad least to cover [this case](https://github.com/micheles/decorator/pull/58)). +This library was largely inspired by [`decorator`](https://github.com/micheles/decorator), and created mainly to cover [this case](https://github.com/micheles/decorator/pull/58) where the need was to create a function wrapper with a different (but close) signature than the wrapped function. -Its objective is to help you create functions dynamically, with accurate and complete signature. The typical use cases are +`makefun` help you create functions dynamically, with accurate and complete signature. The typical use cases are - you want to create a function `g` with a signature that is derived from the signature of a function `f` provided at runtime (for example the signature of a user-entered function). For example `g` has the same signature than `f`, or has one additional parameter, etc. + - you want to wrap a function `f` provided at runtime, into some code of your own, and you want to expose the wrapper with the same signature (e.g. to create a function proxy) or with a derived signature (one more argument, etc.) - + +It currently supports two ways to define the signature of the created function + + - from strings, e.g. `'foo(a, b=1)'` + - from `Signature` objects, either manually created, or obtained from use of the `inspect.signature` (or its backport `funcsigs.signature`) method. + + ## Installing ```bash @@ -69,12 +76,64 @@ but unfortunately `inspect.signature` is not able to detect them so the generate ### 2- Creating functions from `Signature` objects -TODO it seems like an interesting and relatively easy feature to add, among others. +Another quite frequent case is that you wish to create a function that has the same signature that another one, or with a signature that derives from another one (for example, you wish to add a parameter). + +To support these use cases, as well as all use cases where you wish to create functions ex-nihilo with any kind of signature, `create_function` is also able to accept a `Signature` object as input. + +For example, you can extract the signature from your function using `inspect.signature`, modify it if needed (below we add a parameter), and then pass it to `create_function`: + +```python +try: # python 3.3+ + from inspect import signature, Signature, Parameter +except ImportError: + from funcsigs import signature, Signature, Parameter + +def foo(b, a=0): + print("foo called: b=%s, a=%s" % (b, a)) + return b, a + +# capture the name and signature of existing function `foo` +func_name = foo.__name__ +original_func_sig = signature(foo) +print("Original Signature: %s" % original_func_sig) + +# modify the signature to add a new parameter +params = list(original_func_sig.parameters.values()) +params.insert(0, Parameter('z', kind=Parameter.POSITIONAL_OR_KEYWORD)) +func_sig = original_func_sig.replace(parameters=params) +print("New Signature: %s" % func_sig) + +# define the handler that should be called +def my_handler(z, *args, **kwargs): + print("my_handler called ! z=%s" % z) + # call the foo function + output = foo(*args, **kwargs) + # return augmented output + return z, output + +# create a dynamic function with the same signature and name +dynamic_fun = create_function(func_sig, my_handler, func_name=func_name) + +# call it +dynamic_fun(3, 2) +``` + +yields + +``` +Original Signature: (b, a=0) +New Signature: (z, b, a=0) + +my_handler called ! z=3 +foo called: b=2, a=0 +``` + +This way you can therefore easily create function wrappers with different signatures: not only adding, but also removing parameters, changing their kind (forcing keyword-only for example), etc. The possibilities are as numerous as the capabilities of the `Signature` objects. ### 3- Advanced topics -#### Positional- and Keyword-only +#### Variable-length, Positional-only and Keyword-only By default, all arguments (including the ones which fell back to default values) will be passed to the handler. You can see it by printing the `__source__` field of the generated function: @@ -90,11 +149,10 @@ def foo(b, a=0): ``` -The `__call_handler_` symbol represents your handler. You see that the variables are passed to it as keywords when possible (`_call_handler_(b=b)`, not simply `_call_handler_(b)`). However in some cases, the function that you want create has positional-only arguments, or variable-length arguments. In this case the generated function will adapt the way it passes the arguments to your handler, as expected: +The `__call_handler_` symbol represents your handler. You see that the variables are passed to it *as keyword arguments* when possible (`_call_handler_(b=b)`, not simply `_call_handler_(b)`). However in some cases, the function that you want create has variable-length arguments. In this case the generated function will adapt the way it passes the arguments to your handler, as expected: ```python -func_signature = "foo(b, *, a=0, **kwargs)" -# b is positional-only, and kwargs is variable-length keyword args +func_signature = "foo(a=0, *args, **kwargs)" dynamic_fun = create_function(func_signature, my_handler) print(dynamic_fun.__source__) ``` @@ -102,15 +160,30 @@ print(dynamic_fun.__source__) prints the following source code: ```python -def foo(b, *, a=0, **kwargs): - return _call_handler_(b, a=a, **kwargs) +def foo(a=0, *args, **kwargs): + return _call_handler_(a=a, *args, **kwargs) ``` -This time you see that `b` is passed as a positional, and `kwargs` is passed with the double-star. +This time you see that `*args` and `kwargs` are passed with their stars. + +Positional-only arguments do not exist as of today in python. They can be declared on a `Signature` object, but then the string version of the signature presents a syntax error for the python compiler: + +```python +try: # python 3.3+ + from inspect import Signature, Parameter +except ImportError: + from funcsigs import Signature, Parameter + +params = [Parameter('a', kind=Parameter.POSITIONAL_ONLY), + Parameter('b', kind=Parameter.POSITIONAL_OR_KEYWORD)] +print(str(Signature(parameters=params))) +``` + +yields `(, b)` in python 2 (`funcsigs`) and `(a, /, b)` in python 3 with `inspect`. + +If a future python version supports positional-only ([PEP457](https://www.python.org/dev/peps/pep-0457/) and [PEP570](https://www.python.org/dev/peps/pep-0570/)), this library will adapt - no change of code will be required, as long as the string representation of `Signature` objects adopts the correct syntax. -!!! note "Syntax support according to versions" - Note that legacy python versions do not support all syntax. The library will adapt to what is accepted in the specific language version that you're using. #### Function reference injection diff --git a/makefun/tests/test_create_from_signature.py b/makefun/tests/test_create_from_signature.py new file mode 100644 index 0000000..f521ec4 --- /dev/null +++ b/makefun/tests/test_create_from_signature.py @@ -0,0 +1,29 @@ +import pytest + +from makefun import create_function + +try: # python 3.3+ + from inspect import signature, Signature, Parameter +except ImportError: + from funcsigs import signature, Signature, Parameter + + +def my_handler(*args, **kwargs): + """This docstring will be used in the generated function by default""" + print("my_handler called !") + return args, kwargs + + +def test_positional_only(): + """Tests that as of today one cannot create positional-only functions""" + + params = [Parameter('a', kind=Parameter.POSITIONAL_ONLY), + Parameter('args', kind=Parameter.VAR_POSITIONAL), + Parameter('kwargs', kind=Parameter.VAR_KEYWORD)] + + func_signature = Signature(parameters=params) + + with pytest.raises(SyntaxError): + dynamic_fun = create_function(func_signature, my_handler, func_name="foo") + print(dynamic_fun.__source__) + assert dynamic_fun(0, 1) == ((1,), {'a': 0}) diff --git a/makefun/tests/test_doc.py b/makefun/tests/test_doc.py index 71d1422..e9b42ae 100644 --- a/makefun/tests/test_doc.py +++ b/makefun/tests/test_doc.py @@ -28,7 +28,7 @@ def my_handler(*args, **kwargs): # first check the source code ref_src = "def foo(b, a=0):\n return _call_handler_(b=b, a=a)\n" - print(dynamic_fun.__source__) + print("Generated Source :\n" + dynamic_fun.__source__) assert dynamic_fun.__source__ == ref_src # then the behaviour @@ -49,30 +49,40 @@ def my_handler(*args, **kwargs): def test_from_sig(): """ Tests that we can create a function from a Signature object """ - # define the signature from an existing function def foo(b, a=0): - pass - func_signature = signature(foo) + print("foo called: b=%s, a=%s" % (b, a)) + return b, a + + # capture the name and signature of existing function `foo` func_name = foo.__name__ + original_func_sig = signature(foo) + print("Original Signature: %s" % original_func_sig) + + # modify the signature to add a new parameter + params = list(original_func_sig.parameters.values()) + params.insert(0, Parameter('z', kind=Parameter.POSITIONAL_OR_KEYWORD)) + func_sig = original_func_sig.replace(parameters=params) + print("New Signature: %s" % func_sig) # define the handler that should be called - def my_handler(*args, **kwargs): - """This docstring will be used in the generated function by default""" - print("my_handler called !") - return args, kwargs + def my_handler(z, *args, **kwargs): + print("my_handler called ! z=%s" % z) + # call the foo function + output = foo(*args, **kwargs) + # return augmented output + return z, output # create the dynamic function - dynamic_fun = create_function(func_signature, my_handler, func_name=func_name) + dynamic_fun = create_function(func_sig, my_handler, func_name=func_name) - # call it and check - args, kwargs = dynamic_fun(2) - assert args == () - assert kwargs == {'a': 0, 'b': 2} - - ref_src = "def foo(b, a=0):\n return _call_handler_(b=b, a=a)\n" - print(dynamic_fun.__source__) + # check the source code + ref_src = "def foo(z, b, a=0):\n return _call_handler_(z=z, b=b, a=a)\n" + print("Generated Source :\n" + dynamic_fun.__source__) assert dynamic_fun.__source__ == ref_src + # then the behaviour + assert dynamic_fun(3, 2) == (3, (2, 0)) + def test_injection(): """ Tests that the function can be injected as first argument when inject_as_first_arg=True """ @@ -89,3 +99,27 @@ def generic_handler(f, *args, **kwargs): func1(1, 2) func2(1, 2) + + +def test_var_length(): + """Demonstrates how variable-length arguments are passed to the handler """ + + # define the handler that should be called + def my_handler(*args, **kwargs): + """This docstring will be used in the generated function by default""" + print("my_handler called !") + return args, kwargs + + func_signature = "foo(a=0, *args, **kwargs)" + dynamic_fun = create_function(func_signature, my_handler) + print(dynamic_fun.__source__) + assert dynamic_fun(0, 1) == ((1,), {'a': 0}) + + +def test_positional_only(): + """Tests that as of today positional-only signatures translate to bad strings """ + + params = [Parameter('a', kind=Parameter.POSITIONAL_ONLY), + Parameter('b', kind=Parameter.POSITIONAL_OR_KEYWORD)] + + assert str(Signature(parameters=params)) in {"(, b)", "(a, /, b)"}