metafunctions 1.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ from importlib.metadata import PackageNotFoundError
2
+ from importlib.metadata import version
3
+
4
+ from metafunctions.api import bind_call_state
5
+ from metafunctions.api import concurrent
6
+ from metafunctions.api import locate_error
7
+ from metafunctions.api import mmap
8
+ from metafunctions.api import node
9
+ from metafunctions.api import recall
10
+ from metafunctions.api import star
11
+ from metafunctions.api import store
12
+
13
+ try:
14
+ __version__ = version("metafunctions")
15
+ except PackageNotFoundError:
16
+ __version__ = "unknown"
17
+
18
+ __all__ = [
19
+ "bind_call_state",
20
+ "concurrent",
21
+ "locate_error",
22
+ "mmap",
23
+ "node",
24
+ "recall",
25
+ "star",
26
+ "store",
27
+ ]
metafunctions/api.py ADDED
@@ -0,0 +1,151 @@
1
+ """
2
+ Utility functions for use in function pipelines.
3
+ """
4
+
5
+ import functools
6
+ import typing as tp
7
+
8
+ from metafunctions import operators
9
+ from metafunctions import util
10
+ from metafunctions.core import CallState
11
+ from metafunctions.core import FunctionMerge
12
+ from metafunctions.core import MetaFunction
13
+ from metafunctions.core import SimpleFunction
14
+ from metafunctions.core.concurrent import ConcurrentMerge
15
+ from metafunctions.map import MergeMap
16
+
17
+
18
+ def node(_func=None, *, name=None):
19
+ """Turn the decorated function into a MetaFunction.
20
+
21
+ Args:
22
+ _func: Internal use. This will be the decorated function if node is used as a decorator
23
+ with no params.
24
+
25
+ Usage:
26
+
27
+ @node
28
+ def f(x):
29
+ <do something cool>
30
+ """
31
+
32
+ def decorator(function):
33
+ newfunc = SimpleFunction(function, name=name)
34
+ return newfunc
35
+
36
+ if not _func:
37
+ return decorator
38
+ return decorator(_func)
39
+
40
+
41
+ def bind_call_state(func):
42
+ @functools.wraps(func)
43
+ def provides_call_state(*args, **kwargs):
44
+ call_state = kwargs.pop("call_state")
45
+ return func(call_state, *args, **kwargs)
46
+
47
+ provides_call_state._receives_call_state = True
48
+ return provides_call_state
49
+
50
+
51
+ def star(meta_function: MetaFunction) -> MetaFunction:
52
+ """
53
+ star calls its Metafunction with *x instead of x.
54
+ """
55
+ fname = str(meta_function)
56
+
57
+ # This convoluted inline `if` just decides whether we should add brackets or not.
58
+ @node(name=f"star{fname}" if fname.startswith("(") else f"star({fname})")
59
+ @functools.wraps(meta_function)
60
+ def wrapper(args, **kwargs):
61
+ return meta_function(*args, **kwargs)
62
+
63
+ return wrapper
64
+
65
+
66
+ def store(key):
67
+ """Store the received output in the meta data dictionary under the given key."""
68
+
69
+ @node(name=f"store('{key}')")
70
+ @bind_call_state
71
+ def storer(call_state, val):
72
+ call_state.data[key] = val
73
+ return val
74
+
75
+ return storer
76
+
77
+
78
+ def recall(key, from_call_state: CallState = None):
79
+ """Retrieve the given key from the meta data dictionary. Optionally, use `from_call_state` to
80
+ specify a different call_state than the current one.
81
+ """
82
+
83
+ @node(name=f"recall('{key}')")
84
+ @bind_call_state
85
+ def recaller(call_state, *_):
86
+ if from_call_state:
87
+ return from_call_state.data[key]
88
+ return call_state.data[key]
89
+
90
+ return recaller
91
+
92
+
93
+ def concurrent(function: FunctionMerge) -> ConcurrentMerge:
94
+ """
95
+ Upgrade the specified FunctionMerge object to a ConcurrentMerge, which runs each of its
96
+ component functions in separate processes. See ConcurrentMerge documentation for more
97
+ information.
98
+
99
+ Usage:
100
+
101
+ c = concurrent(long_running_function + other_long_running_function)
102
+ c(input_data) # The two functions run in parallel
103
+ """
104
+ return ConcurrentMerge(function)
105
+
106
+
107
+ def mmap(function: tp.Callable, operator: tp.Callable = operators.concat) -> MergeMap:
108
+ """
109
+ Upgrade the specified function to a MergeMap, which calls its single function once per input,
110
+ as per the builtin `map` (https://docs.python.org/3.6/library/functions.html#map).
111
+
112
+ Consider the name 'mmap' to be a placeholder for now.
113
+ """
114
+ return MergeMap(MetaFunction.make_meta(function), operator)
115
+
116
+
117
+ def locate_error(
118
+ meta_function: MetaFunction,
119
+ use_color=util.system_supports_color(), # noqa: B008
120
+ ) -> SimpleFunction:
121
+ """
122
+ Wrap the given MetaFunction with an error handler that adds location information to any
123
+ exception raised therein.
124
+
125
+ Usage:
126
+ cmp = locate_error(a | b | c)
127
+ cmp()
128
+ """
129
+
130
+ def with_location(*args, call_state, **kwargs):
131
+ new_e = None
132
+ try:
133
+ return meta_function(*args, call_state=call_state, **kwargs)
134
+ except Exception as e: # noqa: BLE001
135
+ if hasattr(e, "location") and e.location:
136
+ # If the exception has location info attached
137
+ location = e.location
138
+ else:
139
+ location = call_state.highlight_active_function()
140
+
141
+ if use_color:
142
+ location = util.color_highlights(location)
143
+ detailed_message = (
144
+ f"{e!s} \n\nOccured in the following function: {location}"
145
+ )
146
+ new_e = type(e)(detailed_message).with_traceback(e.__traceback__)
147
+ new_e.__cause__ = e.__cause__
148
+ raise new_e
149
+
150
+ with_location._receives_call_state = True
151
+ return node(with_location, name=str(meta_function))
@@ -0,0 +1,19 @@
1
+ from .base import DeferredValue
2
+ from .base import FunctionChain
3
+ from .base import FunctionMerge
4
+ from .base import MetaFunction
5
+ from .base import SimpleFunction
6
+ from .call_state import CallState
7
+ from .concurrent import ConcurrentMerge
8
+ from .decorators import manage_call_state
9
+
10
+ __all__ = [
11
+ "CallState",
12
+ "ConcurrentMerge",
13
+ "DeferredValue",
14
+ "FunctionChain",
15
+ "FunctionMerge",
16
+ "MetaFunction",
17
+ "SimpleFunction",
18
+ "manage_call_state",
19
+ ]
@@ -0,0 +1,294 @@
1
+ import abc
2
+ import functools
3
+ import itertools
4
+ import typing as tp
5
+
6
+ from metafunctions import exceptions
7
+ from metafunctions import operators
8
+ from metafunctions.core.call_state import CallState
9
+ from metafunctions.core.decorators import binary_operation
10
+ from metafunctions.core.decorators import manage_call_state
11
+
12
+
13
+ class MetaFunction(metaclass=abc.ABCMeta):
14
+ # Metafunctions will pass call state to any function with this attribute set to true
15
+ _receives_call_state = True
16
+ _function_join_str = ""
17
+
18
+ @abc.abstractmethod
19
+ def __init__(self, *args, **kwargs):
20
+ """A MetaFunction is a function that contains other functions. When executed, it calls the
21
+ functions it contains.
22
+ """
23
+ self._functions = []
24
+
25
+ @abc.abstractmethod
26
+ def __call__(self, *args, call_state=None, **kwargs):
27
+ """Call the functions contained in this MetaFunction"""
28
+
29
+ def __str__(self):
30
+ return "({})".format(
31
+ f" {self._function_join_str} ".join(str(f) for f in self.functions)
32
+ )
33
+
34
+ @property
35
+ def functions(self):
36
+ return self._functions
37
+
38
+ @staticmethod
39
+ def make_meta(function):
40
+ """Wrap the given function in a metafunction, unless it's already a metafunction"""
41
+ if not isinstance(function, MetaFunction):
42
+ return SimpleFunction(function)
43
+ return function
44
+
45
+ @staticmethod
46
+ def defer_value(value):
47
+ """Wrap the given value in a DeferredValue object, (which returns its value when called)."""
48
+ return DeferredValue(value)
49
+
50
+ @staticmethod
51
+ def new_call_state():
52
+ return CallState()
53
+
54
+ ### Operator overloads ###
55
+ @binary_operation
56
+ def __or__(self, other):
57
+ return FunctionChain.combine(self, other)
58
+
59
+ @binary_operation
60
+ def __ror__(self, other):
61
+ return FunctionChain.combine(other, self)
62
+
63
+ @binary_operation
64
+ def __and__(self, other):
65
+ return FunctionMerge.combine(operators.concat, self, other)
66
+
67
+ @binary_operation
68
+ def __rand__(self, other):
69
+ return FunctionMerge.combine(operators.concat, other, self)
70
+
71
+ @binary_operation
72
+ def __add__(self, other):
73
+ return FunctionMerge(operators.add, (self, other))
74
+
75
+ @binary_operation
76
+ def __radd__(self, other):
77
+ return FunctionMerge(operators.add, (other, self))
78
+
79
+ @binary_operation
80
+ def __sub__(self, other):
81
+ return FunctionMerge(operators.sub, (self, other))
82
+
83
+ @binary_operation
84
+ def __rsub__(self, other):
85
+ return FunctionMerge(operators.sub, (other, self))
86
+
87
+ @binary_operation
88
+ def __mul__(self, other):
89
+ return FunctionMerge(operators.mul, (self, other))
90
+
91
+ @binary_operation
92
+ def __rmul__(self, other):
93
+ return FunctionMerge(operators.mul, (other, self))
94
+
95
+ @binary_operation
96
+ def __truediv__(self, other):
97
+ return FunctionMerge(operators.truediv, (self, other))
98
+
99
+ @binary_operation
100
+ def __rtruediv__(self, other):
101
+ return FunctionMerge(operators.truediv, (other, self))
102
+
103
+ @binary_operation
104
+ def __matmul__(self, other):
105
+ from metafunctions.api import star
106
+
107
+ return FunctionChain.combine(self, star(other))
108
+
109
+ @binary_operation
110
+ def __rmatmul__(self, other):
111
+ from metafunctions.api import star
112
+
113
+ return FunctionChain.combine(other, star(self))
114
+
115
+
116
+ class FunctionChain(MetaFunction):
117
+ _function_join_str = "|"
118
+
119
+ def __init__(self, *functions):
120
+ """A FunctionChain is a metafunction that calls its functions in sequence, passing the
121
+ results of the first function subsequent functions.
122
+ """
123
+ super().__init__()
124
+ self._functions = functions
125
+
126
+ @manage_call_state
127
+ def __call__(self, *args, **kwargs):
128
+ f_iter = iter(self._functions)
129
+ result = next(f_iter)(*args, **kwargs)
130
+ for f in f_iter:
131
+ result = f(result, **kwargs)
132
+ return result
133
+
134
+ def __repr__(self):
135
+ return f"{self.__class__.__name__}{self.functions}"
136
+
137
+ @classmethod
138
+ def combine(cls, *funcs):
139
+ """Merge chains; i.e., combine all FunctionChains in `funcs` into a single FunctionChain."""
140
+ new_funcs = []
141
+ for f in funcs:
142
+ if type(f) is cls:
143
+ new_funcs.extend(f.functions)
144
+ else:
145
+ new_funcs.append(f)
146
+ return cls(*new_funcs)
147
+
148
+
149
+ class FunctionMerge(MetaFunction):
150
+ _character_to_operator: tp.ClassVar = {
151
+ "+": operators.add,
152
+ "-": operators.sub,
153
+ "*": operators.mul,
154
+ "/": operators.truediv,
155
+ "&": operators.concat,
156
+ }
157
+ _operator_to_character: tp.ClassVar = {
158
+ v: k for k, v in _character_to_operator.items()
159
+ }
160
+
161
+ def __init__(self, merge_func: tp.Callable, functions: tuple, function_join_str=""):
162
+ """
163
+ A FunctionMerge merges its functions by executing all of them and passing their results to
164
+ `merge_func`.
165
+
166
+ Behaviour of __call__:
167
+
168
+ FunctionMerge does not pass all positional arguments to all of its functions. Rather, given
169
+ `f=FunctionMerge()` when f is called with `f(*args)`,
170
+
171
+ * if len(args) == 1, each component function is called with args[0]
172
+ * if len(args) > 1 <= len(functions), function n is called with arg n. Any remaining
173
+ functions after all args have been exhausted are called with no args.
174
+ * if len(args) < len(functions), a MetaFunction CallError is raised.
175
+
176
+ Args:
177
+ function_join_str: If you're using a `merge_func` that is not one of the standard operator
178
+ functions, use this argument to provide a custom character to use in string formatting. If
179
+ not provided, we default to using str(merge_func).
180
+ """
181
+ super().__init__()
182
+ self._merge_func = merge_func
183
+ self._functions = functions
184
+ self._function_join_str = function_join_str or self._operator_to_character.get(
185
+ merge_func, str(merge_func)
186
+ )
187
+
188
+ @manage_call_state
189
+ def __call__(self, *args, **kwargs):
190
+ args_iter, func_iter = self._get_call_iterators(args)
191
+
192
+ results = []
193
+ # Note that args_iter appears first in the zip. This is because I know its len is <=
194
+ # len(func_iter) (I asserted so above). In zip, if the first iterator is longer than the
195
+ # second, the first will be advanced one extra time, because zip has already called next()
196
+ # on the first iterator before discovering that the second has been exhausted.
197
+ for arg, f in zip(args_iter, func_iter):
198
+ results.append(self._call_function(f, (arg,), kwargs))
199
+
200
+ # Any extra functions are called with no input
201
+ results.extend([self._call_function(f, (), kwargs) for f in func_iter])
202
+ return self._merge_func(*results)
203
+
204
+ def __repr__(self):
205
+ return f"{self.__class__.__name__}({self._merge_func}, {self.functions})"
206
+
207
+ @classmethod
208
+ def combine(cls, merge_func: tp.Callable, *funcs, function_join_str=None):
209
+ """Combine FunctionMerges. If consecutive FunctionMerges have the same merge_funcs, combine
210
+ them into a single FunctionMerge.
211
+
212
+ NOTE: combine does not check to make sure the merge_func can accept the new number of
213
+ arguments, or that combining is appropriate for the operator. (e.g., it is inappropriate to
214
+ combine FunctionMerges where order of operations matter. 5 / 2 / 3 != 5 / (2 / 3))
215
+ """
216
+ new_funcs = []
217
+ for f in funcs:
218
+ if isinstance(f, cls) and f._merge_func is merge_func:
219
+ new_funcs.extend(f.functions)
220
+ else:
221
+ new_funcs.append(f)
222
+ return cls(merge_func, tuple(new_funcs), function_join_str=function_join_str)
223
+
224
+ def _get_call_iterators(self, args):
225
+ """Do length checking and return (`args_iter`, `call_iter`), iterables of arguments and
226
+ self.functions. Call them using zip. Note that len(args) can be less than
227
+ len(self.functions), and remaining functions should be called with no argument.
228
+ """
229
+ args_iter = iter(args)
230
+ func_iter = iter(self.functions)
231
+ if len(args) > len(self.functions):
232
+ raise exceptions.CallError(
233
+ f"{self} takes 1 or <= {len(self.functions)} arguments, but {len(args)} were given"
234
+ )
235
+ if len(args) == 1:
236
+ args_iter = itertools.repeat(next(args_iter))
237
+
238
+ return args_iter, func_iter
239
+
240
+ def _call_function(self, f, args: tuple, kwargs: dict):
241
+ """This function receives one function, and the args and kwargs that should be used to call
242
+ that function. It returns the result of the function call. This gets its own method so that
243
+ subclasses can customize its behaviour.
244
+ """
245
+ return f(*args, **kwargs)
246
+
247
+
248
+ class SimpleFunction(MetaFunction):
249
+ def __init__(self, function, name=None):
250
+ """A MetaFunction-aware wrapper around a single function
251
+ The `bind` parameter causes us to pass a meta object as the first argument to our inherited function, but it is only respected if the wrapped function is not another metafunction.
252
+ """
253
+ # An interesting side effect of wraps: it causes simplefunctions to collapse into each
254
+ # other. Because calling wraps on a function copies all that function's attributes to the
255
+ # new function, we copy _function, etc from the wrapped function. Essentially
256
+ # absorbing it. I'm not sure if that's good or bad.
257
+ functools.wraps(function)(self)
258
+
259
+ super().__init__()
260
+ self._function = function
261
+ self._name = name or getattr(function, "__name__", False) or str(function)
262
+
263
+ @manage_call_state
264
+ def __call__(self, *args, call_state, **kwargs):
265
+ if getattr(self._function, "_receives_call_state", False):
266
+ kwargs["call_state"] = call_state
267
+ return self._function(*args, **kwargs)
268
+
269
+ def __repr__(self):
270
+ return f"{self.__class__.__name__}({self.functions[0]!r})"
271
+
272
+ def __str__(self):
273
+ return self._name
274
+
275
+ @property
276
+ def functions(self):
277
+ return (self._function,)
278
+
279
+
280
+ class DeferredValue(SimpleFunction):
281
+ def __init__(self, value):
282
+ """A simple Deferred Value. Returns `value` when called. Equivalent to lambda x: x."""
283
+ self._value = value
284
+ self._name = repr(value)
285
+
286
+ def __call__(self, *args, **kwargs):
287
+ return self._value
288
+
289
+ def __repr__(self):
290
+ return f"{self.__class__.__name__}({self._value!r})"
291
+
292
+ @property
293
+ def functions(self):
294
+ return (self,)
@@ -0,0 +1,97 @@
1
+ from collections import OrderedDict
2
+ from collections import defaultdict
3
+ from collections import namedtuple
4
+
5
+ from metafunctions import util
6
+
7
+
8
+ class CallState:
9
+ Node = namedtuple("Node", "function insert_index")
10
+
11
+ def __init__(self):
12
+ """
13
+ A call tree keeps track of function execution order in metafunctions. This is used to
14
+ accurately determine the location of the currently active function, and to identify
15
+ exception locations. It can be thought of as a metafunction aware call stack.
16
+ """
17
+ # Meta entry is conceptually the root node of the call tree
18
+ self._meta_entry = None
19
+ self.active_node = None
20
+ self._nodes_visited = 0
21
+
22
+ # A dictionary of {child: parent} functions
23
+ self._parents = OrderedDict()
24
+
25
+ # A dictionary of {parent: [children]}, in call order
26
+ self._children = defaultdict(list)
27
+ self.data = {}
28
+
29
+ def push(self, f):
30
+ """
31
+ Push a function onto the tree
32
+ """
33
+ if self._meta_entry is None:
34
+ node = self.Node(f, self._nodes_visited)
35
+ self._meta_entry = node
36
+ else:
37
+ node = self.Node(f, self._nodes_visited)
38
+ self._parents[node] = self.active_node
39
+ self._children[self.active_node].append(node)
40
+ self._nodes_visited += 1
41
+ self.active_node = node
42
+
43
+ def pop(self):
44
+ """Remove last inserted f from the call tree."""
45
+ try:
46
+ node, parent = self._parents.popitem()
47
+ except KeyError:
48
+ m = self._meta_entry
49
+ self._meta_entry = None
50
+ self.active_node = None
51
+ return m
52
+ self._children.pop(node, None)
53
+ self.active_node = parent
54
+ return node[0]
55
+
56
+ def iter_parent_nodes(self, node):
57
+ """
58
+ Return an iterator over all parents of this node in the tree, ending with the meta_entry
59
+ point.
60
+ """
61
+ # if node isn't in _parents, it must be the meta entry
62
+ parent = self._parents.get(node, self._meta_entry)
63
+ yield parent
64
+ if parent is not self._meta_entry:
65
+ yield from self.iter_parent_nodes(parent)
66
+
67
+ def highlight_active_function(self):
68
+ """
69
+ Return a formatted string showing the location of the most recently called function in
70
+ call_state.
71
+
72
+ Consider this a 'you are here' when called from within a function pipeline.
73
+ """
74
+ current_function = self.active_node.function
75
+ current_name = str(current_function)
76
+ new_name = util.highlight(current_name)
77
+
78
+ # rename active function in parent (if active function isn't in parent, active function
79
+ # becomes parent)
80
+ for parent in self.iter_parent_nodes(self.active_node):
81
+ parent_name = str(parent.function)
82
+
83
+ # Note we count occurences of current name in functions we've called so far. We do this
84
+ # because it's possible previous functions contain the name of this function.
85
+ name_count = sum(
86
+ str(n.function).count(current_name) for n in self._children[parent]
87
+ )
88
+
89
+ new_name = util.replace_nth(parent_name, current_name, name_count, new_name)
90
+ current_function = parent.function
91
+ current_name = parent_name
92
+
93
+ # if new parent name hasn't changed (meaning it didn't contain the name we're
94
+ # highlighting), highlight the parent name
95
+ if new_name == parent_name:
96
+ new_name = util.highlight(new_name)
97
+ return new_name