Skip to content

Commit f8bc8b6

Browse files
committed
ENH(OP): improve Results/Provides validations, ...
+ enh: validations before were missing len-mismatching. + test: +TCs for validation error-msgs. + enh: +JETSAM var: `results` --> `results_fn` + `results_op`
1 parent ee03296 commit f8bc8b6

File tree

5 files changed

+100
-34
lines changed

5 files changed

+100
-34
lines changed

docs/source/plotting.rst

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,9 @@ with the folllowing properties, as a debug aid:
8989
'outputs': None,
9090
'plan': ExecutionPlan(inputs=('a',), outputs=(), steps:
9191
+--FunctionalOperation(name='screamer', needs=['a'], provides=['foo'], fn='scream')),
92-
'provides': ['foo'],
93-
'results': None,
92+
'provides': None,
93+
'results_fn': None,
94+
'results_op': None,
9495
'solution': {'a': None}}
9596

9697

@@ -123,9 +124,11 @@ The following annotated attributes *might* have meaningfull value on an exceptio
123124
the names eventually the graph needed from the operation;
124125
a subset of the above, and not always what has been declared in the operation.
125126

126-
``results``
127-
the values dict, if any; it maybe a *zip* of the provides
128-
with the actual returned values of the function, ot the raw results.
127+
``fn_results``
128+
the raw results of the operation's fuction, if any
129+
130+
``op_results``
131+
the results, always a dictionary, as matched with operation's `provides`
129132

130133
``executed```
131134
a set with the operation nodes & instructions executed till the error happened.

graphtik/op.py

Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,16 @@
33
"""About operation nodes (but not net-ops to break cycle)."""
44

55
import abc
6-
from collections.abc import Hashable
6+
import logging
7+
from collections.abc import Hashable, Iterable, Mapping
8+
9+
from boltons.setutils import IndexedSet as iset
710

811
from .base import Plotter, aslist, jetsam
912
from .modifiers import optional, sideffect
1013

14+
log = logging.getLogger(__name__)
15+
1116

1217
def reparse_operation_data(name, needs, provides):
1318
"""
@@ -128,12 +133,44 @@ def __repr__(self):
128133
f"provides={provides!r}, fn{returns_dict_marker}={fn_name!r})"
129134
)
130135

131-
def _validate_results(self, results: dict, real_provides: list):
132-
if set(results) != set(real_provides):
136+
def _zip_results_with_provides(self, results, real_provides: iset) -> dict:
137+
"""Zip results with expected "real" (without sideffects) `provides`."""
138+
if not real_provides: # All outputs were sideffects?
139+
if results:
140+
## Do not scream,
141+
# it is common to call a function for its sideffects,
142+
# which happens to return an irrelevant value.
143+
log.warning(
144+
"Ignoring result(%s) because no `provides` given!\n %s",
145+
results,
146+
self,
147+
)
148+
results = {}
149+
elif not self.returns_dict:
150+
nexpected = len(real_provides)
151+
152+
if nexpected > 1 and (
153+
not isinstance(results, Iterable) or len(results) != nexpected
154+
):
155+
raise ValueError(
156+
f"Expected x{nexpected} ITERABLE results, got: {results}"
157+
)
158+
159+
if nexpected == 1:
160+
results = [results]
161+
162+
results = dict(zip(real_provides, results))
163+
164+
if self.returns_dict:
165+
if not isinstance(results, Mapping):
166+
raise ValueError(f"Expected dict-results, got: {results}\n {self}")
167+
if set(results) != real_provides:
133168
raise ValueError(
134169
f"Results({results}) mismatched provides({real_provides})!\n {self}"
135170
)
136171

172+
return results
173+
137174
def compute(self, named_inputs, outputs=None) -> dict:
138175
try:
139176
args = [
@@ -150,35 +187,27 @@ def compute(self, named_inputs, outputs=None) -> dict:
150187
if isinstance(n, optional) and n in named_inputs
151188
}
152189

153-
# Don't expect sideffect outputs.
154-
provides = [n for n in self.provides if not isinstance(n, sideffect)]
155-
156-
results = self.fn(*args, **optionals)
157-
158-
if not provides:
159-
# All outputs were sideffects?
160-
results = {}
161-
162-
elif not self.returns_dict:
163-
if len(provides) == 1:
164-
results = [results]
165-
166-
results = dict(zip(provides, results))
190+
results_fn = self.fn(*args, **optionals)
167191

168-
self._validate_results(results, provides)
192+
provides = iset(n for n in self.provides if not isinstance(n, sideffect))
193+
results_op = self._zip_results_with_provides(results_fn, provides)
169194

170195
if outputs:
171196
outputs = set(n for n in outputs if not isinstance(n, sideffect))
172-
results = {key: val for key, val in results.items() if key in outputs}
197+
# Ignore sideffect outputs.
198+
results_op = {
199+
key: val for key, val in results_op.items() if key in outputs
200+
}
173201

174-
return results
202+
return results_op
175203
except Exception as ex:
176204
jetsam(
177205
ex,
178206
locals(),
179207
"outputs",
180208
"provides",
181-
"results",
209+
"results_fn",
210+
"results_op",
182211
operation="self",
183212
args=lambda locs: {
184213
"args": locs.get("args"),

test/test_base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ class Op:
138138
operation(name="test", needs=["a"], provides=["b"])(_scream).compute,
139139
named_inputs={"a": 1},
140140
),
141-
"outputs provides results operation args".split(),
141+
"outputs provides results_fn results_op operation args".split(),
142142
),
143143
(
144144
fnt.partial(
@@ -170,7 +170,7 @@ def test_jetsam_sites_screaming_func(acallable, expected_jetsam):
170170
operation(name="test", needs=["a"], provides=["b"])(_scream).compute,
171171
named_inputs=None,
172172
),
173-
"outputs provides results operation args".split(),
173+
"outputs provides results_fn results_op operation args".split(),
174174
),
175175
(
176176
fnt.partial(

test/test_graphtik.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,14 @@
1010
import pytest
1111

1212
import graphtik.network as network
13-
from graphtik import (AbortedException, abort_run, compose, operation,
14-
optional, sideffect)
13+
from graphtik import (
14+
AbortedException,
15+
abort_run,
16+
compose,
17+
operation,
18+
optional,
19+
sideffect,
20+
)
1521
from graphtik.op import Operation
1622

1723

test/test_op.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def compute(self):
2727
pass
2828

2929

30-
def test_operation_repr_smoke(opname, opneeds, opprovides):
30+
def test_repr_smoke(opname, opneeds, opprovides):
3131
# Simply check __repr__() does not crash on partial attributes.
3232
kw = locals().copy()
3333
kw = {name[2:]: arg for name, arg in kw.items()}
@@ -39,7 +39,7 @@ def test_operation_repr_smoke(opname, opneeds, opprovides):
3939
str(op)
4040

4141

42-
def test_operation_repr_returns_dict():
42+
def test_repr_returns_dict():
4343
assert (
4444
str(operation(lambda: None, returns_dict=True)())
4545
== "FunctionalOperation(name=None, needs=[], provides=[], fn{}='<lambda>')"
@@ -69,19 +69,47 @@ def test_operation_repr_returns_dict():
6969
(("", "a", [()]), ValueError("All `provides` must be str")),
7070
],
7171
)
72-
def test_operation_validation(opargs, exp):
72+
def test_validation(opargs, exp):
7373
if isinstance(exp, Exception):
7474
with pytest.raises(type(exp), match=str(exp)):
7575
reparse_operation_data(*opargs)
7676
else:
7777
assert reparse_operation_data(*opargs) == exp
7878

7979

80-
def test_operation_returns_dict():
80+
def test_returns_dict():
8181
result = {"a": 1}
8282

8383
op = operation(lambda: result, provides="a", returns_dict=True)()
8484
assert op.compute({}) == result
8585

8686
op = operation(lambda: 1, provides="a", returns_dict=False)()
8787
assert op.compute({}) == result
88+
89+
90+
@pytest.fixture(params=[None, ["a", "b"]])
91+
def asked_outputs(request):
92+
return request.param
93+
94+
95+
@pytest.mark.parametrize(
96+
"result", [None, 3.14, (), "", "foobar", ["b", "c", "e"], {"f"}]
97+
)
98+
def test_results_validation_iterable_BAD(result, asked_outputs):
99+
op = operation(lambda: result, provides=["a", "b"], returns_dict=False)()
100+
with pytest.raises(ValueError, match="Expected x2 ITERABLE results"):
101+
op.compute({}, outputs=asked_outputs)
102+
103+
104+
@pytest.mark.parametrize("result", [None, 3.14, [], "foo", ["b", "c", "e"], {"a", "b"}])
105+
def test_dict_results_validation_BAD(result, asked_outputs):
106+
op = operation(lambda: result, provides=["a", "b"], returns_dict=True)()
107+
with pytest.raises(ValueError, match="Expected dict-results"):
108+
op.compute({}, outputs=asked_outputs)
109+
110+
111+
@pytest.mark.parametrize("result", [{"a": 1}, {"a": 1, "b": 2, "c": 3}])
112+
def test_dict_results_validation_MISMATCH(result, asked_outputs):
113+
op = operation(lambda: result, provides=["a", "b"], returns_dict=True)()
114+
with pytest.raises(ValueError, match="mismatched provides"):
115+
op.compute({}, outputs=asked_outputs)

0 commit comments

Comments
 (0)