From 2486dbbebb5f7b76ed9822bdb7ce4aedc6843584 Mon Sep 17 00:00:00 2001 From: "Teal, D" Date: Fri, 9 Dec 2022 16:46:43 -0800 Subject: [PATCH 1/9] Adding flag for a basic auto-wrapping functionality This commit includes a significant update to `Door`, introducing the concept of "auto-wrapping" functions by passing a wrapped flag alongside the expected parameters. This commit also fixes a significant issue (which is addressed in the issue and will be brought up in the relevant pull request) with type-checking being on by default whenever using Door. This hadn't caused issues in the past because ~~my code is always perfect~~ because it wasn't implicitly covered in any tests. This means 0.4.0 will not be fully backwards-compatible. --- porchlight/door.py | 124 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 122 insertions(+), 2 deletions(-) diff --git a/porchlight/door.py b/porchlight/door.py index da19244..dd1c4db 100644 --- a/porchlight/door.py +++ b/porchlight/door.py @@ -111,6 +111,15 @@ def __init__( Note, this is not the same as a DynamicDoor, and internal variables/updating is not handled as with a DynamicDoor. This just calls Door's initializer on the output of the base function. + + source : `str`, optional + If a non-empty string is provided, it will be parsed as source. + This is used to avoid using :py:meth:`inspect.getsourcelines()`. + This is primarily used by :py:class:`~porchlight.door.Door` for + wrapping special functions. Writing source directly is always + preferable. + + Any str passed to this is safe; the source will never be executed. """ self._returned_def_to_door = returned_def_to_door self._base_function = function @@ -421,9 +430,97 @@ class Door(BaseDoor): """Inherits from and extends :class:`~porchlight.door.BaseDoor`""" def __init__( - self, function: Callable = None, *, argument_mapping: dict = {} + self, + function: Callable = None, + *, + argument_mapping: dict = {}, + wrapped: bool = False, + arguments: dict = {}, + keyword_args: dict = {}, + return_vals: List = [], + name: str = "", + typecheck: bool = False, ): + """Initializes the :py:class:`~porchlight.door.Door` object using a + callable. + + Arguments + --------- + + function : Callable + A callable object to be parsed by :py:class:`~BaseDoor`. + + argument_mapping : dict, keyword-only, optional + Maps parameters automatically by name. For example, to have a Door + accept "a" and "b" ans arguments instead of "x" and "y", one could + use + + .. code-block:: python + + def fxn(x): + y = 2 * x + return y + + my_door = Door(fxn, argument_mapping={'x': 'a', 'y': 'b'}) + + to accomplish what would otherwise require wrapping the function + yourself. + + wrapped : bool, keyword-only, optional + If `True`, will not parse the function using + :py:class:`~porchlight.door.BaseDoor`. Instead, it will take user + arguments and generate a function wrapper using the following + keyword-only arguments: + + - arguments + - keyword_args + - return_vals + + And this wrapper will be used to initialize the + :py:class:`~porchlight.door.BaseDoor` properties. + + arguments : dict, keyword-only, optional + Arguments to be passed to the function if it is wrapped. Does not + override :py:class:`~porchlight.door.BaseDoor` if ``wrapped`` is + ``False``. + + keyword_args : dict, keyword-only, optional + Corresponds to keyword arguments that may be passed positionally. + Only used when ``wrapped`` is ``True``. + + name : str, keyword-only, optional + Overrides the default name for the Door if provided. + + typecheck : :py:obj:`bool`, optional + If `True`, the `Door` object will assert that arguments passed + to `__call__` (when the `Door` itself is called like a + function) have the type expected by type annotations and any user + specifications. By default, this is `True`. + """ self.argmap = argument_mapping + self.name = name + self.wrapped = wrapped + self.typecheck = typecheck + + if name: + self.__name__ = name + + # For wrapped functions, circumvent normal initialization. + if self.wrapped: + self._initialize_wrapped_function( + arguments, keyword_args, return_vals + ) + + # In these cases, there's no reason to use a decorator. + if function is None: + msg = "Auto-wrapped functions must be passed directly." + + logger.error(msg) + raise DoorError(msg) + + self._base_function = function + + return self.function_initialized = False if function is None: @@ -431,6 +528,23 @@ def __init__( self.__call__(function) + def _initialize_wrapped_function( + self, arguments, keyword_args, return_vals + ): + """Initializes a function that is auto-wrapped by + :py:class:`~porchlight.door.Door` instead of being passed to + :py:class:`~porchlight.door.BaseDoor` + """ + if not self.name: + self.name = "AutoWrappedFunctionDoor" + self.__name__ = self.name + + self.arguments = arguments + self.keyword_args = keyword_args + self.return_vals = return_vals + + self.function_initialized = True + def __call__(self, *args, **kwargs): if not self.function_initialized: # Need to recieve the function. @@ -449,7 +563,9 @@ def __call__(self, *args, **kwargs): raise TypeError(msg) function = args[0] - super().__init__(function) + + super().__init__(function, typecheck=self.typecheck) + self.function_initialized = True # Perform any necessary argument mapping. @@ -457,6 +573,10 @@ def __call__(self, *args, **kwargs): return self + if self.wrapped: + result = self._base_function(*args, **kwargs) + return result + # Check argument mappings. if not self.argmap: # Just pass arguments normally From 19f50043169b61fe99b6d05c5777897678929ba9 Mon Sep 17 00:00:00 2001 From: "Teal, D" Date: Fri, 9 Dec 2022 16:52:56 -0800 Subject: [PATCH 2/9] Removing unnecessary docstring. --- porchlight/door.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/porchlight/door.py b/porchlight/door.py index dd1c4db..e1555a5 100644 --- a/porchlight/door.py +++ b/porchlight/door.py @@ -111,15 +111,6 @@ def __init__( Note, this is not the same as a DynamicDoor, and internal variables/updating is not handled as with a DynamicDoor. This just calls Door's initializer on the output of the base function. - - source : `str`, optional - If a non-empty string is provided, it will be parsed as source. - This is used to avoid using :py:meth:`inspect.getsourcelines()`. - This is primarily used by :py:class:`~porchlight.door.Door` for - wrapping special functions. Writing source directly is always - preferable. - - Any str passed to this is safe; the source will never be executed. """ self._returned_def_to_door = returned_def_to_door self._base_function = function From f3f2982f60c51e673c634f048dc7b55b879e0356 Mon Sep 17 00:00:00 2001 From: "Teal, D" Date: Fri, 9 Dec 2022 17:03:11 -0800 Subject: [PATCH 3/9] Updating `Door` tests to account for typechecking --- porchlight/tests/test_door.py | 42 ++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/porchlight/tests/test_door.py b/porchlight/tests/test_door.py index 6f74c77..fc0bbb5 100644 --- a/porchlight/tests/test_door.py +++ b/porchlight/tests/test_door.py @@ -10,6 +10,7 @@ import typing import logging import os +import math logging.basicConfig(filename=f"{os.getcwd()}/porchlight_unittest.log") @@ -59,12 +60,20 @@ def test_fxn(x: int) -> int: y = 2 * x return y - door = Door(test_fxn) + door = Door(test_fxn, typecheck=True) # Call the Door with erroneous types based on annotations. with self.assertRaises(ParameterError): door(x="6") + # Typechecking off + door = Door(test_fxn, typecheck=False) + door(x="6") + + # Default functionality should be typechecking off + door = Door(test_fxn) + door(x=[6]) + def test_required_arguments(self): # This property is critical for the functioning of the Neighborhood # object, since it determines what areguments are passed and *how* they @@ -315,6 +324,37 @@ def test1(x, y: int, z=1) -> int: # Changing the argument mapping with the setter. test1.argument_mapping = {"hello_again": "x", "world_two": "z"} + # @unittest.skip("Because") + def test_auto_wrapping(self): + # Should work for any type of callable. + def my_func(x: int) -> int: + y = x + 1 + return y + + tests = [ + ( + my_func, + {"arguments": {"hello": int}, "return_vals": ["world"]}, + ), + ( + lambda x: x + 1, + {"arguments": {"x": int}, "return_vals": ["y"]}, + ), + ( + math.cos, + { + "arguments": {"theta": int}, + "return_vals": ["cos_theta"], + }, + ), + ] + + for fxn, kwargs in tests: + my_door = Door(fxn, wrapped=True, **kwargs) + + for arg, value in kwargs.items(): + self.assertEqual(getattr(my_door, arg), value) + if __name__ == "__main__": unittest.main() From 415c3c239f6cda6af7381853e7903cc30c6ee0c6 Mon Sep 17 00:00:00 2001 From: "Teal, D" Date: Fri, 9 Dec 2022 17:45:45 -0800 Subject: [PATCH 4/9] Updating documentation. The documentation now includes references to the `utils` library, references to `__init__` and `__call__` for all classes, and some bug fixes in the docs. --- docs/conf.py | 14 +++++++++++++ docs/index.rst | 3 ++- docs/source/door.rst | 6 ++++++ docs/source/neighborhood.rst | 8 ++++++++ docs/source/param.rst | 2 ++ docs/source/utils.rst | 29 +++++++++++++++++++++++++++ porchlight/utils/inspect_functions.py | 2 +- 7 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 docs/source/utils.rst diff --git a/docs/conf.py b/docs/conf.py index 7417d13..930672f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -68,3 +68,17 @@ # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["source/_static"] # html_css_files = ["porchlight.css"] + + +# Include initialization and call special methods to be documented. From +# https://stackoverflow.com/questions/5599254/how-to-use-sphinxs-autodoc +# -to-document-a-classs-init-self-method +def skip(app, what, name, obj, would_skip, options): + if name in ["__init__", "__call__"]: + return False + + return would_skip + + +def setup(app): + app.connect("autodoc-skip-member", skip) diff --git a/docs/index.rst b/docs/index.rst index f71e05a..f213e97 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -5,7 +5,7 @@ Welcome to |porchlight|'s documentation! coupling Python functions and managing shared data. .. toctree:: - :maxdepth: 2 + :maxdepth: 3 :caption: Contents: other/about @@ -13,6 +13,7 @@ coupling Python functions and managing shared data. source/neighborhood source/door source/param + source/utils Indices and tables ================== diff --git a/docs/source/door.rst b/docs/source/door.rst index ca631fc..4c7976b 100644 --- a/docs/source/door.rst +++ b/docs/source/door.rst @@ -1,5 +1,11 @@ `door` module ============= +The `door` module contains classes that represent interfaces to python +functions. They are primarily used on functions that have been defined in pure +python, but can be extended to include arbitrary callables. + .. automodule:: porchlight.door :members: + :show-inheritance: + :private-members: diff --git a/docs/source/neighborhood.rst b/docs/source/neighborhood.rst index 2a84e01..98ca6e4 100644 --- a/docs/source/neighborhood.rst +++ b/docs/source/neighborhood.rst @@ -1,5 +1,13 @@ `neighborhood` module ===================== +`neighborhood` contains the |Neighborhood| class, which acts as a mediator +class between different functions that have been re-cast as +:py:class:`~porchlight.door.Door` objects. + .. automodule:: porchlight.neighborhood :members: + :show-inheritance: + :private-members: + +.. |Neighborhood| replace:: :py:class:`~porchlight.neighborhood.Neighborhood` diff --git a/docs/source/param.rst b/docs/source/param.rst index 1974bdb..9a5063e 100644 --- a/docs/source/param.rst +++ b/docs/source/param.rst @@ -3,3 +3,5 @@ .. automodule:: porchlight.param :members: + :show-inheritance: + :private-members: diff --git a/docs/source/utils.rst b/docs/source/utils.rst new file mode 100644 index 0000000..f7483b3 --- /dev/null +++ b/docs/source/utils.rst @@ -0,0 +1,29 @@ +`utils` module +============== + +The `utils` module contains utility functions and classes to assist in +|porchlight|'s internal functioning. + +.. automodule:: porchlight.utils + :members: + :show-inheritance: + :private-members: + +Inspection utilities +-------------------- + +.. automodule:: porchlight.utils.inspect_functions + :members: + :show-inheritance: + :private-members: + +Typing utilities +---------------- + +.. automodule:: porchlight.utils.typing_functions + :members: + :show-inheritance: + :private-members: + + +.. |porchlight| replace:: **porchlight** diff --git a/porchlight/utils/inspect_functions.py b/porchlight/utils/inspect_functions.py index d08ce8f..0c789a2 100644 --- a/porchlight/utils/inspect_functions.py +++ b/porchlight/utils/inspect_functions.py @@ -1,4 +1,4 @@ -"""Tools for introspection of functions extending what :py:module:`inspect` can +"""Tools for introspection of functions extending what :py:mod:`inspect` can do. """ import inspect From 462e051dacc71e3e2579c8796866e34db0383dc2 Mon Sep 17 00:00:00 2001 From: "Teal, D" Date: Fri, 9 Dec 2022 18:04:02 -0800 Subject: [PATCH 5/9] More comprehensive test for auto-wrapping. --- porchlight/tests/test_door.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/porchlight/tests/test_door.py b/porchlight/tests/test_door.py index fc0bbb5..fc1ef75 100644 --- a/porchlight/tests/test_door.py +++ b/porchlight/tests/test_door.py @@ -11,6 +11,7 @@ import logging import os import math +import random logging.basicConfig(filename=f"{os.getcwd()}/porchlight_unittest.log") @@ -324,7 +325,6 @@ def test1(x, y: int, z=1) -> int: # Changing the argument mapping with the setter. test1.argument_mapping = {"hello_again": "x", "world_two": "z"} - # @unittest.skip("Because") def test_auto_wrapping(self): # Should work for any type of callable. def my_func(x: int) -> int: @@ -355,6 +355,30 @@ def my_func(x: int) -> int: for arg, value in kwargs.items(): self.assertEqual(getattr(my_door, arg), value) + # Actually run the Door + my_door = Door(my_func, wrapped=True, **tests[0][1]) + expected_output = [my_func(x) for x in range(10)] + output = [my_door(x) for x in range(10)] + + self.assertEqual(expected_output, output) + + # Test a slightly more complicated door. + def test1(x: int, *, y=0) -> int: + z = x ** y + return z + + kwargs = { + "arguments": {"x": int}, + "keyword_args": {"y": 0}, + "return_vals": ["z"], + } + + normal_door = Door(test1) + wrapped_door = Door(test1, wrapped=True, **kwargs) + + self.assertEqual(normal_door(5), wrapped_door(5)) + self.assertEqual(normal_door(-500, y=2), wrapped_door(-500, y=2)) + if __name__ == "__main__": unittest.main() From 00474e27b5af16e42c6e8aa2516e674c2525eeb5 Mon Sep 17 00:00:00 2001 From: "Teal, D" Date: Fri, 9 Dec 2022 18:22:26 -0800 Subject: [PATCH 6/9] `if` statements to catch bad parameter names `Door.map_arguments` will now handle parameter names that are in return values but *not* in the arugment list. --- porchlight/door.py | 9 ++++++--- porchlight/tests/test_door.py | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/porchlight/door.py b/porchlight/door.py index e1555a5..5824efb 100644 --- a/porchlight/door.py +++ b/porchlight/door.py @@ -619,13 +619,16 @@ def map_arguments(self): logger.error(msg) raise DoorError(msg) - if old_name not in self.arguments: + if old_name not in self.arguments and not any( + old_name in retvals for retvals in self.return_vals + ): msg = f"{old_name} is not a valid argument for {self.name}" logger.error(msg) raise DoorError(msg) - self.arguments[mapped_name] = self.arguments[old_name] - del self.arguments[old_name] + if old_name in self.arguments: + self.arguments[mapped_name] = self.arguments[old_name] + del self.arguments[old_name] # Change keyword arguments as well. if old_name in self.keyword_args: diff --git a/porchlight/tests/test_door.py b/porchlight/tests/test_door.py index fc1ef75..99bef27 100644 --- a/porchlight/tests/test_door.py +++ b/porchlight/tests/test_door.py @@ -325,6 +325,28 @@ def test1(x, y: int, z=1) -> int: # Changing the argument mapping with the setter. test1.argument_mapping = {"hello_again": "x", "world_two": "z"} + def test_argument_mapping_return_values(self): + # Below works as expected. The return value is visible as 'why'. + @Door(argument_mapping={"ecks": "x", "why": "y"}) + def test1(x, y): + y = x + 1 + return y + + # This raises a DoorError + @Door(argument_mapping={"ecks": "x", "why": "y"}) + def test2(x): + y = x + 1 + return y + + # These tests should work exactly the same in terms of input/output. + def testval(): + return random.randint(-10, 10) + + tests = [[testval(), testval()] for _ in range(100)] + + for x, y in tests: + self.assertEqual(test1(x, y), test2(x)) + def test_auto_wrapping(self): # Should work for any type of callable. def my_func(x: int) -> int: From bf9f6dabe0614356c945e28c7b0275464abe964a Mon Sep 17 00:00:00 2001 From: Teal Date: Fri, 9 Dec 2022 18:33:57 -0800 Subject: [PATCH 7/9] Update README.md This has been redundant a while now. --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 0b0e3c1..f4b1706 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,6 @@ neighborhood.add_function(increase_x) print(neighborhood) ``` -Although this is the current extent of documentation, there should be some more -complete documentation within the next couple weeks/months. - Documentation ----------- From fbaaf061f5ad72e378a1c90f7b6f115bee1210f8 Mon Sep 17 00:00:00 2001 From: Teal Date: Fri, 9 Dec 2022 18:45:06 -0800 Subject: [PATCH 8/9] Update README.md adding the logo <3 --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index f4b1706..3a1b01f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +porchlight logo. A snake's head erupts from the bottom of a porchlight casing, reaching towards a spinning triangular pyramid. The pyramid radiates bright, saturated, multicolored light. + [porchlight](https://porchlight.readthedocs.io/en/latest/) ========== From 892bd7e4f5ac2823e965d2be4dab6428162cdd85 Mon Sep 17 00:00:00 2001 From: "Teal, D" Date: Fri, 9 Dec 2022 18:51:18 -0800 Subject: [PATCH 9/9] Bumping version to 0.4.0 --- docs/conf.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 930672f..dd2833f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,7 +23,7 @@ author = "D J Teal" # The full version, including alpha/beta/rc tags -release = "0.3.1" +release = "0.4.0" # -- General configuration --------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index 57d2c7f..9bdd9c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "porchlight" -version = "0.3.1" +version = "0.4.0" description = "A function-managing package for models and systems with shared variables." authors = ["Teal, D "] license = "GNU General Public License v3.0 or later"