diff --git a/README.md b/README.md index 0b0e3c1..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/) ========== @@ -47,9 +49,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 ----------- diff --git a/docs/conf.py b/docs/conf.py index 7417d13..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 --------------------------------------------------- @@ -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/door.py b/porchlight/door.py index da19244..5824efb 100644 --- a/porchlight/door.py +++ b/porchlight/door.py @@ -421,9 +421,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 +519,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 +554,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 +564,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 @@ -508,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 6f74c77..99bef27 100644 --- a/porchlight/tests/test_door.py +++ b/porchlight/tests/test_door.py @@ -10,6 +10,8 @@ import typing import logging import os +import math +import random logging.basicConfig(filename=f"{os.getcwd()}/porchlight_unittest.log") @@ -59,12 +61,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 +325,82 @@ 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: + 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) + + # 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() 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 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"