├── LICENSE ├── README.md ├── better_partial ├── __init__.py └── better_partial.py └── setup.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Christopher Grimm 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Better partial function application in Python 2 | 3 | Install with: 4 | 5 | ``` 6 | pip install better-partial 7 | ``` 8 | 9 | My library provides both a more ergonomic and expressive way of partially applying functions than `functools.partial`. We begin by decorating a function with our `partial` operator. Let's consider the following function: 10 | 11 | ```python 12 | from better_partial import partial, _ 13 | # Note that "_" can be imported under a different name if it clashes with your conventions 14 | 15 | @partial 16 | def f(a, b, c, d, e): 17 | return a, b, c, d, e 18 | ``` 19 | 20 | We can then evaluate `f` like a standard function: 21 | ```python 22 | f(1, 2, 3, 4, 5) # --> (1, 2, 3, 4, 5) 23 | ``` 24 | but under the hood, my decorator now enables partial applications of `f`. Suppose we wanted to produce a function with all of `f`'s arguments fixed except for `c`. We can accomplish this via the placeholder `_` as follows: 25 | ```python 26 | g = f(1, 2, _, 4, 5) 27 | g(3) # --> (1, 2, 3, 4, 5) 28 | ``` 29 | 30 | Alternatively, we might want to produce a function in which *only* `c` is specified. We can accomplish this using `...`: 31 | ```python 32 | g = f(..., c=3) 33 | g(1, 2, 4, 5) # --> (1, 2, 3, 4, 5) 34 | ``` 35 | 36 | `...` must be specified as the last positional argument and indicates that all following positional arguments should be treated as placeholders. This means that we can specify `a` and `d` as follows: 37 | ```python 38 | g = f(1, ..., d=4) 39 | g(2, 3, 5) # --> (1, 2, 3, 4, 5) 40 | ``` 41 | 42 | The functions returned by partial applications are themselves partially applicable: 43 | ```python 44 | g = f(..., e=5) 45 | h = g(_, 2, 3, 4) 46 | h(1) # --> (1, 2, 3, 4, 5) 47 | ``` 48 | 49 | This enables a diversity of ways to partially apply functions. Consider the following equivalent expressions for `(1, 2, 3, 4, 5)`: 50 | ```python 51 | f(1, 2, 3, 4, 5) 52 | f(_, 2, _, 4, _)(1, 3, 5) 53 | f(1, _, 3, ...)(2, 4, 5) 54 | f(..., a=1, e=5)(_, 3, _)(2, 4) 55 | f(..., e=5)(..., d=4)(1, 2, 3) 56 | f(1, ..., e=5)(2, ..., d=4)(3) 57 | f(..., e=5)(..., d=4)(..., c=3)(..., b=2)(..., a=1) 58 | f(_, _, _, _, _)(1, 2, 3, 4, 5) 59 | f(...)(1, 2, 3, 4, 5) 60 | ``` 61 | 62 | -------------------------------------------------------------------------------- /better_partial/__init__.py: -------------------------------------------------------------------------------- 1 | from better_partial.better_partial import partial, _ 2 | name = 'better_partial' 3 | 4 | __all__ = [ 5 | 'partial', 6 | '_', 7 | ] -------------------------------------------------------------------------------- /better_partial/better_partial.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from functools import wraps 3 | from inspect import Parameter 4 | from typing import NamedTuple, Union, Tuple, Dict, Any, List 5 | from enum import Enum 6 | 7 | 8 | class FillingMode(Enum): 9 | NOT_FILLED = 0 10 | PLACEHOLDER = 1 11 | FILLED_BY_DEFAULT = 2 12 | FILLED = 3 13 | 14 | 15 | class Placeholder: 16 | pass 17 | 18 | 19 | _ = Placeholder() 20 | 21 | 22 | Binding = Dict[str, Union[int, str, Tuple[int, str]]] 23 | Filling = Dict[str, Tuple[FillingMode, Any]] 24 | 25 | 26 | def create_binding(sig: inspect.Signature) -> Binding: 27 | param_to_accessor = {} 28 | for pos, (name, param) in enumerate(sig.parameters.items()): 29 | if param.kind == Parameter.POSITIONAL_ONLY: 30 | param_to_accessor[name] = pos 31 | elif param.kind == Parameter.KEYWORD_ONLY: 32 | param_to_accessor[name] = name 33 | else: 34 | param_to_accessor[name] = (pos, name) 35 | return param_to_accessor 36 | 37 | 38 | def create_filling(sig: inspect.Signature, binding: Binding) -> Filling: 39 | filling = {} 40 | for name in binding: 41 | if sig.parameters[name].default != inspect._empty: 42 | filling[name] = (FillingMode.FILLED_BY_DEFAULT, sig.parameters[name].default) 43 | else: 44 | filling[name] = (FillingMode.NOT_FILLED, None) 45 | return filling 46 | 47 | 48 | def update_filling(old_filling: Filling, binding: Binding, args: List[Any], kwargs: Dict[str, Any]) -> Filling: 49 | filling = old_filling.copy() 50 | 51 | position_to_params = {} 52 | for param, accessor in binding.items(): 53 | if isinstance(accessor, int): 54 | position_to_params[accessor] = param 55 | elif isinstance(accessor, tuple): 56 | position_to_params[accessor[0]] = param 57 | 58 | for name, arg in kwargs.items(): 59 | accessor = binding[name] 60 | if isinstance(accessor, int): 61 | raise Exception(f'Cannot fill position only argument "{name}" with keyword.') 62 | if filling[name][0] == FillingMode.FILLED: 63 | raise Exception(f'Argument "{name}" already filled.') 64 | if arg is _: 65 | filling[name] = (FillingMode.PLACEHOLDER, None) 66 | else: 67 | filling[name] = (FillingMode.FILLED, arg) 68 | 69 | for pos, arg in enumerate(args): 70 | if pos not in position_to_params: 71 | raise Exception(f'Function does not have argument with position {pos}.') 72 | name = position_to_params[pos] 73 | accessor = binding[name] 74 | if isinstance(accessor, str): 75 | raise Exception(f'Cannot fill keyword only argument "{name}" with positional argument.') 76 | if filling[name][0] == FillingMode.FILLED: 77 | raise Exception(f'Argument "{name}" already filled.') 78 | if arg is _: 79 | filling[name] = (FillingMode.PLACEHOLDER, None) 80 | else: 81 | filling[name] = (FillingMode.FILLED, arg) 82 | return filling 83 | 84 | 85 | def raise_if_missing_argument(filling: Filling) -> bool: 86 | missing_args = [] 87 | for name, (filling_mode, val) in filling.items(): 88 | if filling_mode == FillingMode.NOT_FILLED: 89 | missing_args.append(name) 90 | if missing_args: 91 | missing_args_string = ', '.join(f"{arg}" for arg in missing_args) 92 | raise Exception(f'Missing arguments: {missing_args_string}.') 93 | 94 | 95 | def mark_not_filled_as_placeholders(old_filling: Filling) -> Filling: 96 | filling = {} 97 | for name, (filling_mode, val) in old_filling.items(): 98 | if filling_mode == FillingMode.NOT_FILLED: 99 | filling[name] = (FillingMode.PLACEHOLDER, None) 100 | else: 101 | filling[name] = (filling_mode, val) 102 | return filling 103 | 104 | 105 | def is_filling_complete(filling: Filling) -> bool: 106 | for name, (filling_mode, val) in filling.items(): 107 | if filling_mode == FillingMode.PLACEHOLDER: 108 | return False 109 | return True 110 | 111 | 112 | def filling_to_args_kwargs(filling: Filling, binding: Binding) -> Tuple[List[Any], Dict[str, Any]]: 113 | positions_and_args = [] 114 | kwargs = {} 115 | missing_args = [] 116 | for name, (filling_mode, val) in filling.items(): 117 | if filling_mode == FillingMode.PLACEHOLDER: 118 | missing_args.append(name) 119 | if isinstance(binding[name], str): 120 | kwargs[name] = val 121 | elif isinstance(binding[name], tuple): 122 | positions_and_args.append((binding[name][0], val)) 123 | else: 124 | positions_and_args.append((binding[name], val)) 125 | args = [arg for pos, arg in sorted(positions_and_args, key=lambda x: x[0])] 126 | if missing_args: 127 | missing_args_string = ', '.join(f"{arg}" for arg in missing_args) 128 | raise Exception(f'Cannot construct args / kwargs. Missing arguments: {missing_args_string}.') 129 | 130 | return args, kwargs 131 | 132 | 133 | def create_partial_signature(signature: inspect.Signature, binding: Binding, filling: Filling) -> inspect.Signature: 134 | positional_only_with_positions = [] 135 | for name, accessor in binding.items(): 136 | if isinstance(accessor, int): 137 | positional_only_with_positions.append((accessor, name)) 138 | positional_only = [name for pos, name in sorted(positional_only_with_positions, key=lambda x: x[0])] 139 | 140 | positional_or_kw_with_positions = [] 141 | for name, accessor in binding.items(): 142 | if isinstance(accessor, tuple): 143 | positional_or_kw_with_positions.append((accessor[0], name)) 144 | positional_or_kw = [name for pos, name in sorted(positional_or_kw_with_positions, key=lambda x: x[0])] 145 | 146 | kw_only = [] 147 | for name, accessor in binding.items(): 148 | if isinstance(accessor, str): 149 | kw_only.append(name) 150 | 151 | def _make_parameter_and_append(name, kind): 152 | if filling[name][0] == FillingMode.PLACEHOLDER: 153 | parameters.append(Parameter(name, kind)) 154 | elif filling[name][0] == FillingMode.FILLED_BY_DEFAULT: 155 | parameters.append(Parameter(name, kind, default=signature.parameters[name].default)) 156 | 157 | parameters = [] 158 | for name in positional_only: 159 | _make_parameter_and_append(name, Parameter.POSITIONAL_ONLY) 160 | for name in positional_or_kw: 161 | _make_parameter_and_append(name, Parameter.POSITIONAL_OR_KEYWORD) 162 | for name in kw_only: 163 | _make_parameter_and_append(name, Parameter.KEYWORD_ONLY) 164 | return inspect.Signature(parameters) 165 | 166 | 167 | def partial(f): 168 | outer_sig = inspect.signature(f) 169 | 170 | for param in outer_sig.parameters.values(): 171 | if param.kind == Parameter.VAR_KEYWORD or param.kind == Parameter.VAR_POSITIONAL: 172 | raise Exception('Better-Partial doesnt handle functions with variable positional or keyword arguments yet.') 173 | 174 | @wraps(f) 175 | def g(*partial_args, **partial_kwargs): 176 | outer_binding = create_binding(outer_sig) 177 | outer_filling = create_filling(outer_sig, outer_binding) 178 | 179 | ellipsis_count = sum(arg == Ellipsis for arg in partial_args) 180 | if ellipsis_count > 1: 181 | raise Exception('Only one Ellipsis can be in args.') 182 | if ellipsis_count == 1: 183 | if partial_args[-1] != Ellipsis: 184 | raise Exception('Ellipsis must be the last positional argument.') 185 | 186 | outer_filling = mark_not_filled_as_placeholders(outer_filling) 187 | partial_args = partial_args[:-1] 188 | 189 | outer_filling = update_filling(outer_filling, outer_binding, partial_args, partial_kwargs) 190 | raise_if_missing_argument(outer_filling) 191 | 192 | if is_filling_complete(outer_filling): 193 | filled_args, filled_kwargs = filling_to_args_kwargs(outer_filling, outer_binding) 194 | return f(*filled_args, **filled_kwargs) 195 | 196 | inner_sig = create_partial_signature(outer_sig, outer_binding, outer_filling) 197 | inner_binding = create_binding(inner_sig) 198 | def h(*args, **kwargs): 199 | # use the inner binding to update the filling 200 | updated_filling = update_filling(outer_filling, inner_binding, args, kwargs) 201 | # use the outer binding to get the args / kwargs from the filling 202 | filled_args, filled_kwargs = filling_to_args_kwargs(updated_filling, outer_binding) 203 | return f(*filled_args, **filled_kwargs) 204 | h.__signature__ = inner_sig 205 | return partial(h) 206 | g.__signature__ = outer_sig 207 | return g 208 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="better_partial", 8 | version="1.0.6", 9 | author="Christopher Grimm", 10 | author_email="cgrimm1994@gmail.com", 11 | description="A more intuitive way to partially apply functions in Python.", 12 | long_description="A more intuitive way to partially apply functions in Python.", 13 | long_description_content_type="text/markdown", 14 | url="https://github.com/chrisgrimm/better_partial", 15 | packages=setuptools.find_packages(), 16 | classifiers=( 17 | "Programming Language :: Python :: 3", 18 | "License :: OSI Approved :: MIT License", 19 | "Operating System :: OS Independent", 20 | ), 21 | ) --------------------------------------------------------------------------------