diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9b38853 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/docs/kwargs-bubbling.md b/docs/kwargs-bubbling.md new file mode 100644 index 0000000..7891c9d --- /dev/null +++ b/docs/kwargs-bubbling.md @@ -0,0 +1,121 @@ +# Kwargs Bubbling + +Wire functions can pass metadata through the graph without polluting function signatures. This is _kwargs bubbling_ - setup kwargs flow through the pipeline in the `ArgsPack`, independent of runtime function calls. + +## The Problem + +Consider passing metadata through a graph pipeline: + +```py +def add(x, y, **metadata): + # Function forced to accept **metadata + # even though it doesn't use it + return x + y + +def multiply(value, **metadata): + # Every function needs **metadata + return value * 2 +``` + +This is noisy. Functions have to accept kwargs they don't use. + +## Kwargs Bubbling + +Wire functions separate runtime kwargs from pipeline metadata: + +```py +from hyperway.edges import wire + +def add(x, y): + # Clean signature + return x + y + +def multiply(value): + # No **kwargs noise + return value * 2 + +# Setup wire with pipeline metadata +wire_add = wire(add, stage='sum', pipeline='data-transform') +wire_mul = wire(multiply, stage='amplify') +``` + +When called: + +```py +result = wire_add(10, 20) +# Function receives: add(10, 20) +# ArgsPack contains: (30, stage='sum', pipeline='data-transform') +``` + +The metadata _bubbles_ through the `ArgsPack`, never touching the function signature. + +## Call-time vs Setup-time + +Two distinct flows: + +**Call-time kwargs** → Go to the function +```py +def add_with_multiplier(x, y, multiplier=1): + return (x + y) * multiplier + +wire_func = wire(add_with_multiplier, pipeline='test') +result = wire_func(10, 20, multiplier=5) +# Function receives: add_with_multiplier(10, 20, multiplier=5) +# ArgsPack contains: (150, pipeline='test') +``` + +**Setup-time kwargs** → Bubble through `ArgsPack` +```py +wire_func = wire(add, stage='sum', meta='important') +result = wire_func(10, 20) +# Function receives: add(10, 20) +# ArgsPack contains: (30, stage='sum', meta='important') +``` + +## Usage in Graphs + +This keeps graph pipelines clean while preserving metadata for debugging, logging, or conditional execution: + +```py +from hyperway.edges import make_edge + +g = Graph() + +# Each wire carries stage metadata +edge1 = g.add(node_a, node_b, through=wire(transform, stage='normalize')) +edge2 = g.add(node_b, node_c, through=wire(validate, stage='check')) + +# Metadata flows through without touching function signatures +g.stepper_prepare(node_a, initial_data) +stepper = g.stepper() + +for rows in stepper: + for caller, argpack in rows: + # Access bubbled metadata + print(f"Stage: {argpack.kwargs.get('stage')}") +``` + +## wire vs wire_partial + +Both support kwargs bubbling: + +**`wire`** - No pre-applied args, just metadata: +```py +wire_func = wire(my_function, pipeline='prod', version='2.0') +result = wire_func(x, y) # Clean call +``` + +**`wire_partial`** - Pre-applied args AND result goes to function: +```py +wire_func = wire_partial(my_function, preset_arg, foo=2) +result = wire_func(runtime_arg, foo=5) # foo=5 overrides foo=2 +# Function receives all merged kwargs +``` + +## Key Insight + +Kwargs bubbling separates concerns: +- **Function signature** = What the function needs to compute +- **Pipeline metadata** = What the graph needs to track + +Functions stay clean. Metadata flows through. The graph stays debuggable. diff --git a/pyproject.toml b/pyproject.toml index 8c008d1..526c4ac 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "hyperway" -version = "1.0.4" +version = "1.0.5" authors = [ { name="Strangemother", email="hyperway@strangemother.com" }, ] diff --git a/sonar-project.properties b/sonar-project.properties index 2e3c534..7330dfa 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -5,7 +5,7 @@ sonar.projectKey=Strangemother_python-hyperway sonar.organization=strangemother sonar.projectName=Python Hyperway -sonar.projectVersion=1.0.4 +sonar.projectVersion=1.0.5 # Source code settings sonar.sources=src/hyperway diff --git a/src/hyperway/__init__.py b/src/hyperway/__init__.py index d6ab5cd..9390a3f 100755 --- a/src/hyperway/__init__.py +++ b/src/hyperway/__init__.py @@ -6,4 +6,5 @@ from .nodes import as_unit, as_units, Unit from .edges import make_edge, Connection from .graph import Graph -from .packer import argspack, argpack, ArgsPack \ No newline at end of file +from .packer import argspack, argpack, ArgsPack +from .stepper import StepperException \ No newline at end of file diff --git a/src/hyperway/edges.py b/src/hyperway/edges.py index 5cecae8..04ca630 100755 --- a/src/hyperway/edges.py +++ b/src/hyperway/edges.py @@ -15,7 +15,36 @@ def is_edge(unit): return isinstance(unit, Connection) -def wire(func, *a, **kw): +def wire_partial(func, *wire_a, **wire_kw): + """Wire function that accepts a standard function, to + later execute as a wire function. + + This _partial_ variant acts like a funtools.partial, allowing + pre-setting of arguments to the wire function. + + def my_wire_func(*a, **kw): + print('WIRING:', a, kw) + return argspack(*a, **kw) + + wire_partial(my_wire_func, 10, 20, foo=2, egg=True) + my_wire_func(30, 40, foo=5) + WIRING: (10, 20, 30, 40) {'foo': 5, 'egg': True} + + Allowing the developer to apply generic functions as a wire + method without handling the argpack. + + c = make_edge(f.add_1, f.add_2, + through=wire(f.mul_5)) + """ + def wrapper(*a, **kw): + """Wrapper wire function.""" + wire_kw.update(kw) + result = func(*wire_a, *a, **wire_kw) + return argspack(result) + return wrapper + + +def wire(func, **wkw): """Wire function that accepts a standard function, to later execute as a wire function @@ -24,11 +53,15 @@ def wire(func, *a, **kw): c = make_edge(f.add_1, f.add_2, through=wire(f.mul_5)) + + The wrapped function is called with all runtime args/kwargs, allowing + it to work with its normal signature. Wire-setup kwargs (wkw) flow + through the ArgsPack for pipeline metadata. """ def wrapper(*a, **kw): """Wrapper wire function.""" result = func(*a, **kw) - return argspack(result, **kw) + return argspack(result, **wkw) return wrapper @@ -105,8 +138,8 @@ def __repr__(self): def __call__(self, *a, _graph=None, **kw): """Call this connection, returning the result of B. This will call A, then the through function (if any), then B.""" - g = _graph or self.on - return self.get_a().process(*a, **kw) + g = self.on if _graph is None else _graph + return self.get_a(g).process(*a, **kw) @property def merge_node(self): diff --git a/src/hyperway/graph/base.py b/src/hyperway/graph/base.py index 5133835..bf8a10d 100644 --- a/src/hyperway/graph/base.py +++ b/src/hyperway/graph/base.py @@ -18,7 +18,9 @@ def resolve_node_connections(self, other): return self.resolve_node_to_nowhere(other) return res - def resolve_node_to_nowhere(self, other): + def resolve_node_to_nowhere(self, other): # py-lint: disable=unused-argument + """Given a node with no connections, return a connection to nowhere. + Override in subclasses to provide custom behaviour.""" return () diff --git a/src/hyperway/packer.py b/src/hyperway/packer.py index 6abbe21..8e5975b 100644 --- a/src/hyperway/packer.py +++ b/src/hyperway/packer.py @@ -67,7 +67,7 @@ def argpack(result=UNDEFINED, *more, **extra): def test_argpack(): a = (1,3,4,5) - d = dict(foo=3, bar=4) + d = {'foo': 3, 'bar': 4} v = argpack(argpack(a, **d)) assert a == v.args[0] diff --git a/src/hyperway/reader.py b/src/hyperway/reader.py index f98233f..66197ab 100644 --- a/src/hyperway/reader.py +++ b/src/hyperway/reader.py @@ -11,7 +11,7 @@ def thin_graph(graph): def flat_graph(graph): - res = tuple() + res = () thin = thin_graph(graph) for node_a, b_nodes in thin.items(): for node_b in b_nodes: diff --git a/src/hyperway/stepper.py b/src/hyperway/stepper.py index df500a0..1996ca0 100755 --- a/src/hyperway/stepper.py +++ b/src/hyperway/stepper.py @@ -7,6 +7,12 @@ is_edge, PartialConnection, get_connections) from .graph.base import is_graph + +class StepperException(Exception): + """Exception raised when stepper encounters an error during execution.""" + pass + + def run_stepper(g, unit_a, val): """Here we can _run_ the graph, executing the chain of connections from A to Z. The _result_ is the end attribute. This may be a graph of @@ -93,6 +99,7 @@ def set_global_expand(expand_func): global expand expand = expand_func + def stepper_c(graph, start_node, argspack): stepper = StepperC(graph) res = stepper.start(start_node, akw=argspack) @@ -191,7 +198,7 @@ def step(self, rows=None, count=1): st_nodes = self.start_nodes if st_nodes is None: # Start node must be something... - raise Exception('start_nodes is None') + raise StepperException('start_nodes is None') self.rows = rows or self.rows or expand(st_nodes, self.start_akw,) while c < count: @@ -364,7 +371,7 @@ def call_one(self, func, akw): return self.call_one_fallthrough(func, akw) - def call_one_fallthrough(self, thing, akw): + def call_one_fallthrough(self, thing, akw): # pylint: disable=unused-argument """ The given function is not A Connection, PartialConnection, Unit, or function (a callable). The last-stage action should occur. @@ -467,6 +474,12 @@ def no_branch(self, func, akw): return self.end_branch(func, akw) def end_branch(self, func, akw): + """The end_branch method is default hanlder when no branches exist on the stepper chain. + This captures the result from the last call, and stores it in the stash for later retrieval. + + return an empty tuple if the stash branch stash ends, else return a tuple of tuples (a row set) + with no destination node. + """ # print(' ... Connections end ...', akw) # A tuple of rows if self.stash_ends: diff --git a/src/hyperway/writer.py b/src/hyperway/writer.py index 9d152ef..d57ea40 100644 --- a/src/hyperway/writer.py +++ b/src/hyperway/writer.py @@ -42,6 +42,8 @@ def write_graphviz(graph, title, **opts): styles = opts.pop('styles', {}) direction = opts.pop('direction', 'TB') + show_view = opts.pop('view', False) + t_format = opts.pop('format', None) print(f'{direction=}') defaults = { # 'format':'svg', @@ -85,8 +87,10 @@ def write_graphviz(graph, title, **opts): t.attr(fontsize=styles.get('fontsize', '12')) t.attr(bgcolor=styles.get('bgcolor', "#00000000")) - # t.view() - # t.format = 'svg' + if show_view: + t.view() + if t_format is not None: + t.format = t_format r_opts = {} directory = opts.get('directory', None) if directory is not None: diff --git a/tests/test_edges.py b/tests/test_edges.py index b30c020..eb22fdb 100644 --- a/tests/test_edges.py +++ b/tests/test_edges.py @@ -9,7 +9,7 @@ import unittest from unittest.mock import patch, MagicMock -from hyperway.edges import make_edge, is_edge, Connection, PartialConnection, as_connections, get_connections +from hyperway.edges import make_edge, is_edge, Connection, PartialConnection, as_connections, get_connections, wire_partial, wire from hyperway.nodes import as_unit from hyperway.packer import argspack, ArgsPack from hyperway.graph import Graph @@ -542,6 +542,92 @@ def func_with_kwargs(a, b=0): # Should execute node A with kwargs (10 + 5 = 15) self.assertEqual(result, 15) + + def test_connection_call_with_explicit_graph(self): + """Connection.__call__ accepts explicit _graph parameter. + + The __call__ method should accept and use an explicit _graph + parameter. This is the primary way to provide graph context for + connection execution, as edge.on is typically None unless explicitly set. + """ + g = Graph() + + # Create connection - note edge.on will be None + edge = g.add(add_ten, add_twenty) + + # Call with explicit graph + with patch('builtins.print'): # Suppress debug output + result = edge(5, _graph=g) + + # Should execute successfully using provided graph + self.assertEqual(result, 15) + + def test_connection_call_without_graph_uses_fallback(self): + """Connection.__call__ works without graph when not needed. + + When _graph is not provided and edge.on is None, the connection + can still execute if the node doesn't require graph resolution. + This tests the `g = _graph or self.on` fallback logic. + """ + g = Graph() + edge = g.add(add_ten, add_twenty) + + # edge.on is None by default + self.assertIsNone(edge.on) + + # Call without _graph should still work (g will be None) + with patch('builtins.print'): # Suppress debug output + result = edge(5) + + # Should execute successfully even without graph reference + self.assertEqual(result, 15) + + def test_connection_call_graph_priority_logic(self): + """Connection.__call__ _graph parameter takes priority over edge.on. + + When both self.on and _graph are provided, the explicit _graph + parameter should take precedence. This verifies the logic: + g = self.on if _graph is None else _graph + + This uses explicit None checking, so even an empty Graph (which is + falsy) will be used when explicitly passed as _graph parameter. + """ + g1 = Graph() # Will have connections + g2 = Graph() # Empty graph + + # Create connection and manually set edge.on + edge = g1.add(add_ten, add_twenty) + edge.on = g1 # Manually set for testing + + # Verify edge.on is set + self.assertIs(edge.on, g1) + + # Test the priority logic: self.on if _graph is None else _graph + # Simulate what happens in Connection.__call__ with different _graph values + + # Scenario 1: _graph is None -> should use edge.on + _graph_param = None + resolved = edge.on if _graph_param is None else _graph_param + self.assertIs(resolved, g1) # _graph is None, so edge.on (g1) is used + + # Scenario 2: _graph is g2 (even though empty) -> should use g2 + _graph_param = g2 + resolved = edge.on if _graph_param is None else _graph_param + self.assertIs(resolved, g2) # _graph is not None, so g2 takes priority + + # Scenario 3: _graph is g1 -> should use g1 + _graph_param = g1 + resolved = edge.on if _graph_param is None else _graph_param + self.assertIs(resolved, g1) # _graph is not None, so g1 takes priority + + # Verify execution works with explicit empty graph + with patch('builtins.print'): # Suppress debug output + result_with_g2 = edge(5, _graph=g2) # Explicit empty graph + result_without_graph = edge(5) # Should use edge.on (g1) + + # Both should execute successfully + self.assertEqual(result_with_g2, 15) + self.assertEqual(result_without_graph, 15) class TestPartialConnectionCall(unittest.TestCase): """Test PartialConnection.__call__ method - direct partial invocation.""" @@ -874,3 +960,257 @@ def test_connection_get_node_key_with_graph(self): node_b_with_graph = edge.get_node_key('b', graph=g) self.assertEqual(node_b_with_graph, edge.b) + +class TestWirePartial(unittest.TestCase): + """Test wire_partial function - partial application for wire functions.""" + + def test_wire_partial_basic_functionality(self): + """wire_partial creates a wrapper that pre-applies arguments. + + wire_partial should act like functools.partial, allowing pre-setting + of arguments to a wire function. The wrapper should return an ArgsPack. + + Example from docstring: + wire_partial(my_wire_func, 10, 20) + # Later called with: (30, 40) + # Results in: my_wire_func(10, 20, 30, 40) + """ + def my_wire_func(*a, **kw): + # Sum all args and kwargs for testing + return sum(a) + sum(kw.values()) + + # Pre-apply arguments 10, 20 + partial_wire = wire_partial(my_wire_func, 10, 20) + + # Call with additional arguments 30, 40 + result = partial_wire(30, 40) + + # Should execute: my_wire_func(10, 20, 30, 40) = 100 + self.assertIsInstance(result, ArgsPack) + self.assertEqual(result.args, (100,)) + + def test_wire_partial_with_kwargs(self): + """wire_partial pre-applies keyword arguments correctly. + + Pre-set keyword arguments should be passed to the underlying function, + with later kwargs merged (later values override pre-set ones). + + Example from docstring: + wire_partial(my_wire_func, 10, 20, foo=2, egg=True) + my_wire_func(30, 40, foo=5) + WIRING: (10, 20, 30, 40) {'foo': 5, 'egg': True} + """ + def my_wire_func(*a, **kw): + return (a, kw) + + # Pre-apply foo=2, egg=True + partial_wire = wire_partial(my_wire_func, 10, 20, foo=2, egg=True) + + # Call with additional kwargs (foo=5 overrides foo=2) + result = partial_wire(30, 40, foo=5) + + # + self.assertIsInstance(result, ArgsPack) + args_result, kwargs_result = result.args, result.kwargs + + self.assertEqual(args_result, (10, 20, 30, 40)) + self.assertEqual(kwargs_result['foo'], 5) # Overridden + self.assertEqual(kwargs_result['egg'], True) # Preserved + + def test_wire_partial_returns_argspack(self): + """wire_partial wrapper always returns an ArgsPack. + + The wrapper must return argspack(result) to maintain compatibility + with the graph execution pipeline. This is the key difference from + raw functools.partial. + """ + def simple_func(x): + return x * 2 + + partial_wire = wire_partial(simple_func, 5) + result = partial_wire() + + # Must be ArgsPack, not raw result + self.assertIsInstance(result, ArgsPack) + self.assertEqual(result.args, (10,)) + + def test_wire_partial_in_connection(self): + """wire_partial can be used as a through function in make_edge. + + This is the primary use case: wrapping a standard function to use + as a wire transform between nodes, with some arguments pre-applied. + """ + def multiplier(pre_mul, value): + return value * pre_mul + + # Create wire that pre-applies multiplier of 5 + wire_mul_5 = wire_partial(multiplier, 5) + + # Create edge with wire function + edge = make_edge(add_one, add_two, through=wire_mul_5) + + # Test with pluck: add_one(10) = 11, mul_5(11) = 55, add_two(55) = 57 + result = edge.pluck(10) + self.assertEqual(result, 57) + + def test_wire_partial_no_args(self): + """wire_partial with no pre-applied args still wraps in ArgsPack. + + Even without pre-applied arguments, wire_partial should create + a wrapper that returns an ArgsPack, making it suitable for use + as a wire function. + """ + def simple_func(x, y): + return x + y + + # No pre-applied args + partial_wire = wire_partial(simple_func) + result = partial_wire(10, 20) + + self.assertIsInstance(result, ArgsPack) + self.assertEqual(result.args, (30,)) + + def test_wire_partial_merges_distinct_kwargs(self): + """wire_partial merges kwargs from pre-set and call-time. + + Pre-set kwargs and call-time kwargs are merged together. + Note: duplicate keys will raise TypeError (Python limitation). + """ + def collect_kwargs(**kw): + return kw + + # Pre-set a, b, c + partial_wire = wire_partial(collect_kwargs, a=1, b=2, c=3) + + # Add distinct kwargs d, e + result = partial_wire(d=4, e=5) + + self.assertIsInstance(result, ArgsPack) + kwargs_result = result.args[0] + + self.assertEqual(kwargs_result['a'], 1) # Pre-set + self.assertEqual(kwargs_result['b'], 2) # Pre-set + self.assertEqual(kwargs_result['c'], 3) # Pre-set + self.assertEqual(kwargs_result['d'], 4) # Call-time + self.assertEqual(kwargs_result['e'], 5) # Call-time + + def test_wire_partial_with_complex_transform(self): + """wire_partial can pre-apply complex transformation parameters. + + Demonstrates using wire_partial to configure a data transformation + function with specific parameters before using it in a graph. + """ + def transform(prefix, suffix, value): + return f"{prefix}_{value}_{suffix}" + + # Create a specific transformer + wire_format = wire_partial(transform, "START", "END") + + edge = make_edge(passthrough, passthrough, through=wire_format) + + # passthrough("hello") = "hello", transform("START", "END", "hello") + result = edge.pluck("hello") + self.assertEqual(result, "START_hello_END") + + +class TestWire(unittest.TestCase): + """Test wire function - simple wrapper for standard functions as wire functions.""" + + def test_wire_basic_functionality(self): + """wire wraps a standard function to return ArgsPack. + + Unlike wire_partial, wire does not pre-apply arguments. It simply + wraps a function to ensure it returns an ArgsPack. + """ + def multiply_by_5(value): + return value * 5 + + wire_func = wire(multiply_by_5) + result = wire_func(10) + + self.assertIsInstance(result, ArgsPack) + self.assertEqual(result.args, (50,)) + + def test_wire_preserves_kwargs(self): + """wire wrapper passes call-time kwargs to func, setup kwargs to ArgsPack. + + The wire wrapper passes runtime args/kwargs to the wrapped function + with its normal signature. Wire-setup kwargs (from wire(func, **wkw)) + flow through the ArgsPack for pipeline metadata. + + This allows clean function calls without needing **kwargs in the signature. + """ + def add_values(x, y): + # Clean function signature - no **kwargs needed! + return x + y + + # Create wire with setup metadata + wire_func = wire(add_values, pipeline_meta='test', stage='transform') + + # Call with normal function args + result = wire_func(10, 20) + + self.assertIsInstance(result, ArgsPack) + self.assertEqual(result.args, (30,)) + # Wire-setup kwargs appear in ArgsPack + self.assertEqual(result.kwargs, {'pipeline_meta': 'test', 'stage': 'transform'}) + + def test_wire_call_time_kwargs_to_function(self): + """wire passes call-time kwargs to the wrapped function. + + Call-time kwargs are passed directly to the function, allowing + normal function signatures with keyword arguments. + """ + def add_with_default(x, y, multiplier=1): + return (x + y) * multiplier + + wire_func = wire(add_with_default, metadata='test') + + # Call with keyword argument + result = wire_func(10, 20, multiplier=2) + + self.assertIsInstance(result, ArgsPack) + self.assertEqual(result.args, (60,)) # (10 + 20) * 2 + # Only wire-setup kwargs in ArgsPack + self.assertEqual(result.kwargs, {'metadata': 'test'}) + + def test_wire_in_connection(self): + """wire can be used as a through function in make_edge. + + Example from docstring: + c = make_edge(f.add_1, f.add_2, through=wire(f.mul_5)) + """ + mul_5 = add_n(0) # Create a simple function + def mul_5_func(v): + return v * 5 + + wire_func = wire(mul_5_func) + edge = make_edge(add_one, add_two, through=wire_func) + + # add_one(10) = 11, mul_5(11) = 55, add_two(55) = 57 + result = edge.pluck(10) + self.assertEqual(result, 57) + + def test_wire_vs_wire_partial(self): + """wire and wire_partial have different use cases. + + wire: wraps a function without pre-applying args + wire_partial: wraps a function WITH pre-applied args + """ + def add_numbers(a, b): + return a + b + + # wire: no pre-applied args + wire_add = wire(add_numbers) + result1 = wire_add(10, 20) + self.assertEqual(result1.args, (30,)) + + # wire_partial: pre-apply first arg + wire_partial_add = wire_partial(add_numbers, 10) + result2 = wire_partial_add(20) + self.assertEqual(result2.args, (30,)) + + # Both return same result but called differently + self.assertEqual(result1.args, result2.args) + + diff --git a/tests/test_stepper_advanced.py b/tests/test_stepper_advanced.py index 0469107..c88747d 100644 --- a/tests/test_stepper_advanced.py +++ b/tests/test_stepper_advanced.py @@ -20,6 +20,7 @@ from hyperway.edges import make_edge, PartialConnection from hyperway.stepper import ( StepperC, + StepperException, StepperIterator, run_stepper, process_forward, @@ -571,12 +572,12 @@ class TestStepperEdgeCases(unittest.TestCase): """Test edge cases and error conditions.""" def test_step_without_start_nodes_raises(self): - """Test that stepping without start_nodes raises an exception.""" + """Test that stepping without start_nodes raises a StepperException.""" g = Graph() stepper = StepperC(g) # Don't set start_nodes - with self.assertRaises(Exception) as context: + with self.assertRaises(StepperException) as context: stepper.step() self.assertIn('start_nodes is None', str(context.exception)) diff --git a/tests/test_writer.py b/tests/test_writer.py index 5efb655..e35fc4c 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -251,6 +251,138 @@ def test_write_graphviz_adds_nodes_and_edges(self, mock_digraph_class): # Should add edges self.assertGreater(mock_digraph.edge.call_count, 0) + @patch('hyperway.writer.graphviz.Digraph') + def test_write_graphviz_with_show_view(self, mock_digraph_class): + """Test that view() is called when show_view=True.""" + mock_digraph = MagicMock() + mock_digraph.render.return_value = "test.gv" + mock_digraph_class.return_value = mock_digraph + + write_graphviz(self.graph, "test", view=True) + + # Should call view() when view=True + mock_digraph.view.assert_called_once() + mock_digraph.render.assert_called_once() + + @patch('hyperway.writer.graphviz.Digraph') + def test_write_graphviz_without_show_view(self, mock_digraph_class): + """Test that view() is NOT called when show_view=False (default).""" + mock_digraph = MagicMock() + mock_digraph.render.return_value = "test.gv" + mock_digraph_class.return_value = mock_digraph + + write_graphviz(self.graph, "test", view=False) + + # Should NOT call view() when view=False + mock_digraph.view.assert_not_called() + mock_digraph.render.assert_called_once() + + @patch('hyperway.writer.graphviz.Digraph') + def test_write_graphviz_default_no_view(self, mock_digraph_class): + """Test that view() is NOT called by default.""" + mock_digraph = MagicMock() + mock_digraph.render.return_value = "test.gv" + mock_digraph_class.return_value = mock_digraph + + # Call without view parameter + write_graphviz(self.graph, "test") + + # Should NOT call view() by default + mock_digraph.view.assert_not_called() + + @patch('hyperway.writer.graphviz.Digraph') + def test_write_graphviz_with_format_parameter(self, mock_digraph_class): + """Test that format attribute is set when format parameter is provided.""" + mock_digraph = MagicMock() + mock_digraph.render.return_value = "test.gv" + mock_digraph_class.return_value = mock_digraph + + write_graphviz(self.graph, "test", format='svg') + + # Should set format attribute on the digraph + self.assertEqual(mock_digraph.format, 'svg') + mock_digraph.render.assert_called_once() + + @patch('hyperway.writer.graphviz.Digraph') + def test_write_graphviz_format_parameter_none(self, mock_digraph_class): + """Test that format attribute is NOT set when format=None.""" + mock_digraph = MagicMock() + mock_digraph.render.return_value = "test.gv" + mock_digraph_class.return_value = mock_digraph + + write_graphviz(self.graph, "test", format=None) + + # format attribute should not be set when format=None + # Check that format was never assigned after initialization + # (it's set in defaults but shouldn't be reassigned) + self.assertEqual(mock_digraph.render.call_count, 1) + + @patch('hyperway.writer.graphviz.Digraph') + def test_write_graphviz_with_directory_and_format(self, mock_digraph_class): + """Test rendering with both directory and format parameters.""" + mock_digraph = MagicMock() + mock_digraph.render.return_value = "output/test.svg" + mock_digraph_class.return_value = mock_digraph + + write_graphviz(self.graph, "test", directory="output", format='svg') + + # Should set format + self.assertEqual(mock_digraph.format, 'svg') + # Should pass directory to render + mock_digraph.render.assert_called_once_with(directory="output") + + @patch('hyperway.writer.graphviz.Digraph') + def test_write_graphviz_view_and_format_together(self, mock_digraph_class): + """Test that both view and format work together.""" + mock_digraph = MagicMock() + mock_digraph.render.return_value = "test.pdf" + mock_digraph_class.return_value = mock_digraph + + write_graphviz(self.graph, "test", view=True, format='pdf') + + # Should call view + mock_digraph.view.assert_called_once() + # Should set format + self.assertEqual(mock_digraph.format, 'pdf') + # Should render + mock_digraph.render.assert_called_once() + + @patch('hyperway.writer.graphviz.Digraph') + def test_write_graphviz_directory_none_not_passed(self, mock_digraph_class): + """Test that directory is not passed to render when None.""" + mock_digraph = MagicMock() + mock_digraph.render.return_value = "test.gv" + mock_digraph_class.return_value = mock_digraph + + write_graphviz(self.graph, "test") + + # Should call render without directory parameter + mock_digraph.render.assert_called_once_with() + + @patch('hyperway.writer.graphviz.Digraph') + def test_write_graphviz_all_options_combined(self, mock_digraph_class): + """Test all highlighted options working together.""" + mock_digraph = MagicMock() + mock_digraph.render.return_value = "renders/test.svg" + mock_digraph_class.return_value = mock_digraph + + result = write_graphviz( + self.graph, + "test_all_options", + view=True, + format='svg', + directory="renders" + ) + + # Should call view + mock_digraph.view.assert_called_once() + # Should set format + self.assertEqual(mock_digraph.format, 'svg') + # Should pass directory to render + mock_digraph.render.assert_called_once_with(directory="renders") + # Should return the digraph + self.assertEqual(result, mock_digraph) + class TestWriteGraphvizWithoutGraphviz(unittest.TestCase): """Test write_graphviz behavior when graphviz is unavailable.""" diff --git a/workspace/order-of-operation.py b/workspace/order-of-operation.py index 7ed652c..21f719e 100755 --- a/workspace/order-of-operation.py +++ b/workspace/order-of-operation.py @@ -23,6 +23,7 @@ c = make_edge(f.add_1, f.add_2, through=wire(f.mul_2)) -assert c.pluck(1) == 10 # (1 + 1) * 2 + 2 == 6 +r = c.pluck(1) +assert r == 6, f"Result was: {r}" # (1 + 1) * 2 + 2 == 6 assert c.pluck(10) == 24 # (10 + 1) * 2 + 2 == 24 diff --git a/workspace/run_2.gv b/workspace/run_2.gv index b98c4a0..f2f9f16 100644 --- a/workspace/run_2.gv +++ b/workspace/run_2.gv @@ -1,25 +1,25 @@ digraph run_2 { graph [rankdir=TB] node [arrowsize=0.8 color="#2299FF" fontcolor="#DDD" fontname=Arial shape=box style=rounded] - 139581092402896 [label="P_add_2.0"] - 139581092402944 [label="P_add_3.0"] - 139581092402992 [label="P_add_4.0"] - 139581092402416 [label="P_add_7.0"] - 139581092402464 [label="P_add_6.0"] - 139581092402512 [label="P_add_5.0"] - 139581092402368 [label="P_add_8.0"] - 139581092402320 [label="P_add_9.0"] - 139581092403040 [label="P_add_5.0"] - 139581092402848 [label="P_add_1.0"] - 139581092402944 -> 139581092402992 - 139581092402320 -> 139581092402368 - 139581092402848 -> 139581092402896 - 139581092402416 -> 139581092402848 - 139581092402368 -> 139581092402416 - 139581092402416 -> 139581092402464 - 139581092402992 -> 139581092403040 - 139581092402896 -> 139581092402944 - 139581092402464 -> 139581092402512 + 128109871881952 [label="P_add_4.0"] + 128109871881424 [label="P_add_9.0"] + 128109871881472 [label="P_add_8.0"] + 128109871881904 [label="P_add_3.0"] + 128109871881808 [label="P_add_1.0"] + 128109871882000 [label="P_add_5.0"] + 128109871881520 [label="P_add_7.0"] + 128109871881616 [label="P_add_5.0"] + 128109871881568 [label="P_add_6.0"] + 128109871881856 [label="P_add_2.0"] + 128109871881520 -> 128109871881808 + 128109871881952 -> 128109871882000 + 128109871881568 -> 128109871881616 + 128109871881808 -> 128109871881856 + 128109871881856 -> 128109871881904 + 128109871881472 -> 128109871881520 + 128109871881904 -> 128109871881952 + 128109871881424 -> 128109871881472 + 128109871881520 -> 128109871881568 overlap=false fontsize=12 bgcolor="#00000000" diff --git a/workspace/run_2.gv.png b/workspace/run_2.gv.png index d756b93..1d6620b 100644 Binary files a/workspace/run_2.gv.png and b/workspace/run_2.gv.png differ