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,145 @@
1
+ import functools
2
+ import os
3
+ import pickle
4
+ from collections import namedtuple
5
+ from multiprocessing import Queue
6
+ from operator import itemgetter
7
+
8
+ from metafunctions import exceptions
9
+ from metafunctions.core.base import FunctionMerge
10
+ from metafunctions.core.decorators import manage_call_state
11
+
12
+ # Result tuple to be sent back from workers. Defined at module level for eas of pickling
13
+ _ConcurrentResult = namedtuple(
14
+ "_ConcurrentResult", "index result call_state_data exception location"
15
+ )
16
+
17
+
18
+ class ConcurrentMerge(FunctionMerge):
19
+ def __init__(self, function_merge: FunctionMerge):
20
+ """A subclass of FunctionMerge that calls each of its component functions in parallel.
21
+
22
+ ConcurrentMerge takes a FunctionMerge object and upgrades it.
23
+ """
24
+ if not isinstance(function_merge, FunctionMerge):
25
+ # This check is necessary because functools.wraps will copy FunctionMerge attributes to
26
+ # objects that are not FunctionMerges, so this init will succeed, then result in errors
27
+ # at call time.
28
+ raise exceptions.CompositionError(
29
+ f"{type(self)} can only upgrade FunctionMerges"
30
+ )
31
+ if not hasattr(os, "fork"):
32
+ raise exceptions.CompositionError(
33
+ f"{type(self).__name__} requires os.fork, and thus is only available on unix"
34
+ )
35
+
36
+ super().__init__(
37
+ function_merge._merge_func,
38
+ function_merge._functions,
39
+ function_merge._function_join_str,
40
+ )
41
+ self._function_merge = function_merge
42
+
43
+ def __str__(self):
44
+ merge_name = str(self._function_merge)
45
+ return (
46
+ f"concurrent{merge_name}"
47
+ if merge_name.startswith("(")
48
+ else f"concurrent({merge_name})"
49
+ )
50
+
51
+ @manage_call_state
52
+ def __call__(self, *args, **kwargs):
53
+ """We fork here, and execute each function in a child process before joining the results
54
+ with _merge_func
55
+ """
56
+ arg_iter, func_iter = self._get_call_iterators(args)
57
+ enumerated_funcs = enumerate(func_iter)
58
+ result_q = Queue()
59
+
60
+ # spawn a child for each function
61
+ children = []
62
+ for arg, (i, f) in zip(arg_iter, enumerated_funcs):
63
+ child_pid = self._process_in_fork(i, f, result_q, (arg,), kwargs)
64
+ children.append(child_pid)
65
+
66
+ # iterate over any remaining functions for which we have no args
67
+ for i, f in enumerated_funcs:
68
+ child_pid = self._process_in_fork(i, f, result_q, (), kwargs)
69
+ children.append(child_pid)
70
+
71
+ # the parent waits for all children to complete
72
+ for pid in children:
73
+ os.waitpid(pid, 0)
74
+
75
+ # then retrieves the results
76
+ results = []
77
+ # iterate over the result q in sorted order
78
+ result_q.put(None)
79
+ for r in sorted(iter(result_q.get, None), key=itemgetter(0)):
80
+ if r.exception:
81
+ raise exceptions.ConcurrentException(
82
+ "Caught exception in child process", location=r.location
83
+ ) from pickle.loads(r.exception)
84
+ kwargs["call_state"].data.update(pickle.loads(r.call_state_data))
85
+ results.append(pickle.loads(r.result))
86
+ return self._merge_func(*results)
87
+
88
+ def _get_call_iterators(self, args):
89
+ return self._function_merge._get_call_iterators(args)
90
+
91
+ def _call_function(self, f, args: tuple, kwargs: dict):
92
+ return self._function_merge._call_function(f, args, kwargs)
93
+
94
+ def _process_in_fork(self, idx, func, result_q, args, kwargs):
95
+ """Call self._call_function in a child process. This function returns the ID of the child
96
+ in the parent process, while the child process calls _call_function, puts the results in
97
+ the provided queues, then exits.
98
+ """
99
+ pid = os.fork()
100
+ if pid:
101
+ return pid
102
+
103
+ # here we are the child
104
+ make_result = functools.partial(
105
+ _ConcurrentResult,
106
+ result=None,
107
+ exception=None,
108
+ index=idx,
109
+ call_state_data=None,
110
+ location="",
111
+ )
112
+
113
+ result = None
114
+ try:
115
+ r = self._call_function(func, args, kwargs)
116
+
117
+ # pickle here, so that we can't crash with pickle errors in the finally clause
118
+ pickled_r = pickle.dumps(r)
119
+ data = pickle.dumps(kwargs["call_state"].data)
120
+ result = make_result(result=pickled_r, call_state_data=data)
121
+ except Exception as e: # noqa: BLE001
122
+ try:
123
+ # In case func does something stupid like raising an unpicklable exception
124
+ pickled_exception = pickle.dumps(e)
125
+ except (AttributeError, pickle.PicklingError): # fmt: skip
126
+ # Python < 3.14 raises AttributeError for local classes; 3.14+ raises PicklingError
127
+ pickled_exception = pickle.dumps(
128
+ AttributeError(f"Unplicklable exception raised in {func}")
129
+ )
130
+ result = make_result(
131
+ exception=pickled_exception,
132
+ location=kwargs["call_state"].highlight_active_function(),
133
+ )
134
+ finally:
135
+ result_q.put(result)
136
+ # it's necessary to explicitly close the result_q and join its background thread here,
137
+ # because the below os._exit won't allow time for any cleanup.
138
+ result_q.close()
139
+ result_q.join_thread()
140
+
141
+ # This is the one place that the python docs say it's normal to use os._exit. Because
142
+ # this is executed in a child process, calling sys.exit can have unintended
143
+ # consequences. e.g., anything above this that catches the resulting SystemExit can
144
+ # cause the child process to stay alive. the unittest framework does this.
145
+ os._exit(0)
@@ -0,0 +1,40 @@
1
+ """
2
+ Internal decorators that are applied to MetaFunction methods, not functions.
3
+ """
4
+
5
+ from collections.abc import Callable
6
+ from functools import wraps
7
+
8
+
9
+ def binary_operation(method):
10
+ """Internal decorator to apply common type checking for binary operations"""
11
+
12
+ @wraps(method)
13
+ def new_operation(self, other):
14
+ if isinstance(other, Callable):
15
+ new_other = self.make_meta(other)
16
+ else:
17
+ new_other = self.defer_value(other)
18
+ return method(self, new_other)
19
+
20
+ return new_operation
21
+
22
+
23
+ def manage_call_state(call_method):
24
+ """Decorates the call method to insure call_state is present in kwargs, or create a new one.
25
+ If the call state isn't active, we assume we are the meta entry point.
26
+ """
27
+
28
+ @wraps(call_method)
29
+ def with_call_state(self, *args, **kwargs):
30
+ try:
31
+ call_state = kwargs["call_state"]
32
+ except KeyError:
33
+ call_state = self.new_call_state()
34
+ kwargs["call_state"] = call_state
35
+ call_state.push(self)
36
+ r = call_method(self, *args, **kwargs)
37
+ call_state.pop()
38
+ return r
39
+
40
+ return with_call_state
@@ -0,0 +1,25 @@
1
+ class MetaFunctionError(Exception):
2
+ pass
3
+
4
+
5
+ class CompositionError(MetaFunctionError, TypeError):
6
+ "An exception that occureds when MetaFunctions are composed incorrectly"
7
+
8
+
9
+ class CallError(MetaFunctionError, TypeError):
10
+ "An exception that occures when a MetaFunction is called incorrectly"
11
+
12
+ def __init__(self, *args, location: str = ""):
13
+ self.location = location
14
+ super().__init__(*args)
15
+
16
+ def __repr__(self):
17
+ return "{}({}, {})".format(
18
+ self.__class__.__name__,
19
+ self.args,
20
+ f"location='{self.location}'" if self.location else "",
21
+ )
22
+
23
+
24
+ class ConcurrentException(CallError):
25
+ "Concurrent specific call errors (e.g., things that aren't picklable)"
metafunctions/map.py ADDED
@@ -0,0 +1,36 @@
1
+ import itertools
2
+ import typing as tp
3
+
4
+ from metafunctions.core.concurrent import FunctionMerge
5
+ from metafunctions.operators import concat
6
+
7
+
8
+ class MergeMap(FunctionMerge):
9
+ def __init__(self, function: tp.Callable, merge_function: tp.Callable = concat):
10
+ """
11
+ MergeMap is a FunctionMerge with only one function. When called, it behaves like the
12
+ builtin `map` function and calls its function once per item in the iterable(s) it receives.
13
+ """
14
+ super().__init__(merge_function, (function,))
15
+
16
+ def _get_call_iterators(self, args):
17
+ """
18
+ Each element in args is an iterable.
19
+ """
20
+ args_iter = zip(*args)
21
+
22
+ # Note that EVERY element in the func iter will be called, so we need to make sure the
23
+ # length of our iterator is the same as the shortest iterable we received.
24
+ shortest_arg = min(args, key=len)
25
+ func_iter = itertools.repeat(self.functions[0], len(shortest_arg))
26
+ return args_iter, func_iter
27
+
28
+ def _call_function(self, f, args: tuple, kwargs: dict):
29
+ """In MergeMap, args will be a single element tuple containing the args for this function."""
30
+ return f(*args[0], **kwargs)
31
+
32
+ def __str__(self):
33
+ return f"mmap({self.functions[0]!s})"
34
+
35
+ def __repr__(self):
36
+ return f"{self.__class__.__name__}({self.functions[0]}, merge_function={self._merge_func})"
@@ -0,0 +1,14 @@
1
+ """
2
+ Extra operators used by MetaFunctions
3
+ """
4
+
5
+ # These are imported for use elsewhere
6
+ from operator import add # noqa: F401
7
+ from operator import mul # noqa: F401
8
+ from operator import sub # noqa: F401
9
+ from operator import truediv # noqa: F401
10
+
11
+
12
+ def concat(*args):
13
+ "concat(1, 2, 3) -> (1, 2, 3)"
14
+ return args
File without changes
@@ -0,0 +1,30 @@
1
+ """
2
+ Some simple nodes to use in tests.
3
+ """
4
+
5
+ from metafunctions.api import node
6
+
7
+
8
+ @node
9
+ def a(x):
10
+ return x + "a"
11
+
12
+
13
+ @node()
14
+ def b(x):
15
+ return x + "b"
16
+
17
+
18
+ @node()
19
+ def c(x):
20
+ return x + "c"
21
+
22
+
23
+ @node
24
+ def d(x):
25
+ return x + "d"
26
+
27
+
28
+ @node
29
+ def e(x):
30
+ return x + "e"
@@ -0,0 +1,87 @@
1
+ from metafunctions.api import bind_call_state
2
+ from metafunctions.api import node
3
+ from metafunctions.api import recall
4
+ from metafunctions.api import store
5
+ from metafunctions.core import CallState
6
+ from metafunctions.core import SimpleFunction
7
+ from metafunctions.tests.simple_nodes import *
8
+ from metafunctions.tests.util import BaseTestCase
9
+
10
+
11
+ class TestUnit(BaseTestCase):
12
+ def test_bind_call_state(self):
13
+ """
14
+ If decorated with bind_call_state, the function receives the call state dictionary as its
15
+ first argument.
16
+ """
17
+
18
+ @node
19
+ @bind_call_state
20
+ def a_(call_state, x):
21
+ self.assertIsInstance(call_state, CallState)
22
+ call_state.data["a"] = "b"
23
+ return x + "a"
24
+
25
+ @node
26
+ @bind_call_state
27
+ def f(call_state, x):
28
+ return x + call_state.data.get("a", "f")
29
+
30
+ self.assertEqual(a("_"), "_a")
31
+ self.assertEqual(f("_"), "_f")
32
+
33
+ cmp = a_ | f
34
+ self.assertEqual(cmp("_"), "_ab")
35
+ cmp = f | a_ | a_ | f + f
36
+ self.assertEqual(cmp("_"), "_faab_faab")
37
+
38
+ def test_node_bracketless(self):
39
+ """
40
+ I'm allowing the node decorator to be applied without calling because this is how both
41
+ celery and function_pipes work.
42
+ """
43
+
44
+ @node
45
+ def a(x):
46
+ return x + "a"
47
+
48
+ @node()
49
+ def b(x):
50
+ return x + "b"
51
+
52
+ self.assertIsInstance(a, SimpleFunction)
53
+ self.assertIsInstance(b, SimpleFunction)
54
+ self.assertEqual((b | a)("_"), "_ba")
55
+
56
+ def test_store(self):
57
+ state = CallState()
58
+ abc = a | b | store("output") | c
59
+ big = (
60
+ a
61
+ | b
62
+ | c + store("ab") + store("ab2")
63
+ | store("abc")
64
+ | recall("ab") + recall("ab2")
65
+ | c + recall("abc")
66
+ )
67
+
68
+ self.assertEqual(abc("_", call_state=state), "_abc")
69
+ self.assertEqual(state.data["output"], "_ab")
70
+ self.assertEqual(big("_"), "_ab_abc_abc_ab_ab")
71
+
72
+ def test_recall(self):
73
+ state = a.new_call_state()
74
+ state.data["k"] = "secret"
75
+
76
+ cmp = a + b | store("k") | c + recall("k")
77
+ self.assertEqual(cmp("_"), "_a_bc_a_b")
78
+
79
+ cmp = a + b | store("k") | c + recall("k") | recall("k", from_call_state=state)
80
+ self.assertEqual(cmp("_"), "secret")
81
+
82
+ def test_str_store(self):
83
+ # this should be possible
84
+ self.assertEqual(str(store("key")), "store('key')")
85
+
86
+ def test_str_recall(self):
87
+ self.assertEqual(str(recall("key")), "recall('key')")
@@ -0,0 +1,40 @@
1
+ from metafunctions.api import node
2
+ from metafunctions.tests.simple_nodes import *
3
+ from metafunctions.tests.util import BaseTestCase
4
+
5
+
6
+ class TestUnit(BaseTestCase):
7
+ def test_broadcast(self):
8
+ @node
9
+ def f(*args):
10
+ return args
11
+
12
+ cmp = (a | b) @ f
13
+ self.assertEqual(cmp("_"), ("_", "a", "b"))
14
+
15
+ rcmp = (1, 2, 3) @ f
16
+ self.assertEqual(rcmp(), (1, 2, 3))
17
+
18
+ def test_str_repr(self):
19
+ cmp = a @ b
20
+ self.assertEqual(str(cmp), "(a | star(b))")
21
+ self.assertEqual(repr(cmp), f"FunctionChain{(a, cmp.functions[1])}")
22
+
23
+ cmp = a | a @ b * 5 / 7 | b & b @ a
24
+ self.assertEqual(
25
+ str(cmp), "(a | (((a | star(b)) * 5) / 7) | (b & (b | star(a))))"
26
+ )
27
+
28
+ cmp = (1, 2, 3) @ a
29
+ self.assertEqual(str(cmp), "((1, 2, 3) | star(a))")
30
+
31
+ cmp = a @ (b & c)
32
+ self.assertEqual(str(cmp), "(a | star(b & c))")
33
+ self.assertEqual(
34
+ repr(cmp),
35
+ f"FunctionChain({a!r}, SimpleFunction({cmp._functions[1]._function}))",
36
+ )
37
+
38
+ def test_upgrade_merge(self):
39
+ aabbcc = (a & b & c) @ (a & b & c)
40
+ self.assertEqual(aabbcc("_"), ("_aa", "_bb", "_cc"))
@@ -0,0 +1,251 @@
1
+ from metafunctions.api import bind_call_state
2
+ from metafunctions.api import locate_error
3
+ from metafunctions.api import mmap
4
+ from metafunctions.api import node
5
+ from metafunctions.core import CallState
6
+ from metafunctions.tests.simple_nodes import *
7
+ from metafunctions.tests.util import BaseTestCase
8
+
9
+
10
+ class TestUnit(BaseTestCase):
11
+ def test_bind_call_state(self):
12
+ # Bound functions receive call state
13
+ @node
14
+ @bind_call_state
15
+ def f(call_state, x):
16
+ self.assertIsInstance(call_state, CallState)
17
+ call_state.data["h"] = "b"
18
+ return x + "f"
19
+
20
+ @node
21
+ def g(x):
22
+ return x + "g"
23
+
24
+ @node
25
+ @bind_call_state
26
+ def h(call_state, x):
27
+ self.assertIsInstance(call_state, CallState)
28
+ return x + call_state.data["h"]
29
+
30
+ fg = f | g | h
31
+ self.assertEqual(fg("_"), "_fgb")
32
+
33
+ def test_provide_call_state(self):
34
+ # A call state you provide is passed to all functions
35
+ @node
36
+ @bind_call_state
37
+ def f(call_state, x):
38
+ self.assertIs(call_state, c)
39
+ call_state.data["h"] = "d"
40
+ return x + "f"
41
+
42
+ @node
43
+ def g(x):
44
+ return x + "g"
45
+
46
+ @node
47
+ @bind_call_state
48
+ def h(call_state, x):
49
+ self.assertIs(call_state, c)
50
+ return x + call_state.data["h"]
51
+
52
+ fg = f | g | h
53
+
54
+ c = CallState()
55
+ self.assertEqual(fg("_", call_state=c), "_fgd")
56
+
57
+ def test_add_remove(self):
58
+ # imagine these are functions
59
+ a, b, c, d = ["a", "b", "c", "d"]
60
+ Node = CallState.Node
61
+
62
+ tree = CallState()
63
+ tree.push(a)
64
+ tree.push(a)
65
+ self.assertEqual(tree.pop(), a)
66
+ self.assertDictEqual(tree._parents, {})
67
+
68
+ tree.push(b)
69
+ tree.push(a)
70
+ self.assertDictEqual(
71
+ tree._parents, {Node(b, 2): Node(a, 0), Node(a, 3): Node(b, 2)}
72
+ )
73
+ self.assertDictEqual(
74
+ tree._children,
75
+ {
76
+ Node(a, 0): [Node(a, 1), Node(b, 2)],
77
+ Node(b, 2): [Node(a, 3)],
78
+ },
79
+ )
80
+
81
+ tree.push(b)
82
+ self.assertDictEqual(
83
+ tree._children,
84
+ {
85
+ Node(a, 0): [Node(a, 1), Node(b, 2)],
86
+ Node(b, 2): [Node(a, 3)],
87
+ Node(a, 3): [Node(b, 4)],
88
+ },
89
+ )
90
+ self.assertDictEqual(
91
+ tree._parents,
92
+ {
93
+ Node(b, 4): Node(a, 3),
94
+ Node(a, 3): Node(b, 2),
95
+ Node(b, 2): Node(a, 0),
96
+ },
97
+ )
98
+
99
+ self.assertEqual(tree.pop(), b)
100
+ self.assertEqual(tree.pop(), a)
101
+ for f in a, b, c, d:
102
+ tree.push(f)
103
+ tree.pop()
104
+ self.assertDictEqual(
105
+ tree._children,
106
+ {
107
+ Node(a, 0): [Node(a, 1), Node(b, 2)],
108
+ Node(b, 2): [
109
+ Node(a, 3),
110
+ Node(a, 5),
111
+ Node(b, 6),
112
+ Node(c, 7),
113
+ Node(d, 8),
114
+ ],
115
+ },
116
+ )
117
+ self.assertDictEqual(
118
+ tree._parents,
119
+ {
120
+ Node(b, 2): Node(a, 0),
121
+ },
122
+ )
123
+
124
+ def test_iter_parent_nodes(self):
125
+ @node
126
+ def l(*args):
127
+ return []
128
+
129
+ @node
130
+ @bind_call_state
131
+ def return_parent_nodes(cs, *args):
132
+ return list(cs.iter_parent_nodes(cs.active_node))
133
+
134
+ def nodes2str(nodes):
135
+ return [(str(p.function), p.insert_index) for p in nodes]
136
+
137
+ def get_parents(f):
138
+ cs = CallState()
139
+ return nodes2str(f("", call_state=cs))
140
+
141
+ self.assertEqual(return_parent_nodes(), [(return_parent_nodes, 0)])
142
+
143
+ # create some crazy compositions to get parents out of
144
+ simple_chain = a | b | c | return_parent_nodes
145
+ self.assertListEqual(get_parents(simple_chain), [(str(simple_chain), 0)])
146
+
147
+ merges = l + (l + (l + return_parent_nodes))
148
+ self.assertListEqual(
149
+ get_parents(merges),
150
+ [
151
+ ("(l + return_parent_nodes)", 4),
152
+ ("(l + (l + return_parent_nodes))", 2),
153
+ ("(l + (l + (l + return_parent_nodes)))", 0),
154
+ ],
155
+ )
156
+
157
+ def test_highlight_active_function(self):
158
+ fmt_index = 7
159
+
160
+ @node
161
+ def ff(x):
162
+ return x + "F"
163
+
164
+ @node
165
+ @bind_call_state
166
+ def f(call_state, x):
167
+ if call_state._nodes_visited == fmt_index:
168
+ location_string = call_state.highlight_active_function()
169
+ self.assertEqual(
170
+ location_string, "(a | b | ff | f | f | ->f<- | f | f)"
171
+ )
172
+ self.assertEqual(x, "_abFff")
173
+ return x + "f"
174
+
175
+ pipe = a | b | ff | f | f | f | f | f
176
+ pipe("_")
177
+
178
+ state = CallState()
179
+ af = a + f
180
+ af("_", call_state=state)
181
+ with self.assertRaises(AttributeError):
182
+ state.highlight_active_function()
183
+
184
+ def test_hightlight_active_function_no_parents(self):
185
+ @node
186
+ @bind_call_state
187
+ def f(cs):
188
+ return cs.highlight_active_function()
189
+
190
+ self.assertEqual(f(), "->f<-")
191
+
192
+ def test_highlight_active_function_multichar(self):
193
+ # Don't fail on long named functions. This is a regression test
194
+ @node
195
+ def fail(x):
196
+ if not x:
197
+ raise ZeroDivisionError("division by zero")
198
+ return x - 1
199
+
200
+ cmp = fail | fail + a
201
+ color = locate_error(cmp, use_color=True)
202
+ no_color = locate_error(cmp, use_color=False)
203
+ with self.assertRaises(ZeroDivisionError) as e:
204
+ color(1)
205
+ self.assertTrue(
206
+ e.exception.args[0].endswith("(fail | (\x1b[31m->fail<-\x1b[0m + a))")
207
+ )
208
+ with self.assertRaises(ZeroDivisionError) as e:
209
+ no_color(1)
210
+ self.assertTrue(e.exception.args[0].endswith("(fail | (->fail<- + a))"))
211
+
212
+ def test_highlight_with_map(self):
213
+ @node
214
+ def no_fail(x):
215
+ return x
216
+
217
+ @node
218
+ def fail(*args):
219
+ raise ZeroDivisionError("division by zero")
220
+
221
+ cmp = a + b | (c & no_fail & fail)
222
+ mapper = locate_error(("aaaaa", "BBBBB") | mmap(cmp), use_color=False)
223
+ with self.assertRaises(ZeroDivisionError) as e:
224
+ mapper()
225
+ self.assertEqual(
226
+ str(e.exception),
227
+ "division by zero \n\nOccured in the following function: "
228
+ "(('aaaaa', 'BBBBB') | mmap(((a + b) | (c & no_fail & ->fail<-))))",
229
+ )
230
+
231
+ def test_tricky_highlight(self):
232
+ def wrapper(mf, new_name=None):
233
+ @node(name=new_name or str(mf))
234
+ @bind_call_state
235
+ def meta_wrapper(cs, *args, **kwargs):
236
+ return mf(*args, call_state=cs, **kwargs)
237
+
238
+ return meta_wrapper
239
+
240
+ @node
241
+ @bind_call_state
242
+ def locator_string(cs, *args):
243
+ return cs.highlight_active_function()
244
+
245
+ cmp = a | b + c | locator_string
246
+
247
+ tim = wrapper(cmp, new_name="tim")
248
+ atim = a | tim
249
+ self.assertEqual(str(tim), "tim")
250
+ self.assertEqual(str(atim), "(a | tim)")
251
+ self.assertEqual(atim("_"), "(a | ->tim<-)")