11# Copyright 2016, Yahoo Inc.
22# Licensed under the terms of the Apache License, Version 2.0. See the LICENSE file associated with the project for terms.
3+ import abc
34import logging
45from collections import namedtuple
56
6- try :
7- from collections import abc
8- except ImportError :
9- import collections as abc
10-
11- from . import plot
12-
13-
147log = logging .getLogger (__name__ )
158
9+ from .modifiers import optional
10+
1611
1712def aslist (i ):
1813 """utility used through out"""
@@ -124,7 +119,7 @@ def jetsam(ex, locs, *salvage_vars: str, annotation="jetsam", **salvage_mappings
124119 raise # noqa #re-raise without ex-arg, not to insert my frame
125120
126121
127- class Operation (object ):
122+ class Operation (abc . ABC ):
128123 """An abstract class representing a data transformation by :meth:`.compute()`."""
129124
130125 def __init__ (self , name = None , needs = None , provides = None , ** kwargs ):
@@ -167,6 +162,7 @@ def __hash__(self):
167162 """
168163 return hash (self .name )
169164
165+ @abc .abstractmethod
170166 def compute (self , named_inputs , outputs = None ):
171167 """
172168 Compute from a given set of inputs an optional set of outputs.
@@ -179,11 +175,9 @@ def compute(self, named_inputs, outputs=None):
179175 the results of running the feed-forward computation on
180176 ``inputs``.
181177 """
182- raise NotImplementedError ( "Abstract %r! cannot compute()!" % self )
178+ pass
183179
184180 def _validate (self ):
185- from .modifiers import optional
186-
187181 if not self .name :
188182 raise ValueError (f"Operation needs a name, got: { self .name } " )
189183
@@ -230,108 +224,129 @@ def __repr__(self):
230224 return f"{ clsname } (name={ self .name !r} , needs={ needs !r} , provides={ provides !r} )"
231225
232226
233- class NetworkOperation (Operation , plot .Plotter ):
234- #: The execution_plan of the last call to compute(), cached as debugging aid.
235- last_plan = None
236- #: set execution mode to single-threaded sequential by default
237- method = "sequential"
238- overwrites_collector = None
239-
240- def __init__ (self , net , method = "sequential" , overwrites_collector = None , ** kwargs ):
241- """
242- :param method:
243- if ``"parallel"``, launches multi-threading.
244- Set when invoking a composed graph or by
245- :meth:`~NetworkOperation.set_execution_method()`.
246-
247- :param overwrites_collector:
248- (optional) a mutable dict to be fillwed with named values.
249- If missing, values are simply discarded.
250-
251- """
252- self .net = net
253- Operation .__init__ (self , ** kwargs )
254- self .set_execution_method (method )
255- self .set_overwrites_collector (overwrites_collector )
227+ class Plotter (abc .ABC ):
228+ """
229+ Classes wishing to plot their graphs should inherit this and ...
256230
257- def _build_pydot ( self , ** kws ):
258- """delegate to network"""
259- kws . setdefault ( "title" , self . name )
260- plotter = self . last_plan or self . net
261- return plotter . _build_pydot ( ** kws )
231+ implement property ``plot`` to return a "partial" callable that somehow
232+ ends up calling :func:`plot.render_pydot()` with the `graph` or any other
233+ args binded appropriately.
234+ The purpose is to avoid copying this function & documentation here around.
235+ """
262236
263- def compute (self , named_inputs , outputs = None ):
237+ def plot (self , filename = None , show = False , ** kws ):
264238 """
265- Solve & execute the graph, sequentially or parallel.
266-
267- :param dict named_inputs:
268- A maping of names --> values that must contain at least
269- the compulsory inputs that were specified when the plan was built
270- (but cannot enforce that!).
271- Cloned, not modified.
272-
239+ :param str filename:
240+ Write diagram into a file.
241+ Common extensions are ``.png .dot .jpg .jpeg .pdf .svg``
242+ call :func:`plot.supported_plot_formats()` for more.
243+ :param show:
244+ If it evaluates to true, opens the diagram in a matplotlib window.
245+ If it equals `-1`, it plots but does not open the Window.
246+ :param inputs:
247+ an optional name list, any nodes in there are plotted
248+ as a "house"
273249 :param outputs:
274- a string or a list of strings with all data asked to compute.
275- If you set this variable to ``None``, all data nodes will be kept
276- and returned at runtime.
277-
278- :returns: a dictionary of output data objects, keyed by name.
279- """
280- try :
281- if isinstance (outputs , str ):
282- outputs = [outputs ]
283- elif not isinstance (outputs , (list , tuple )) and outputs is not None :
284- raise ValueError (
285- "The outputs argument must be a list or None, was: %s" , outputs
286- )
287-
288- net = self .net
289-
290- # Build the execution plan.
291- self .last_plan = plan = net .compile (named_inputs .keys (), outputs )
292-
293- solution = plan .execute (
294- named_inputs , self .overwrites_collector , self .execution_method
295- )
296-
297- return solution
298- except Exception as ex :
299- jetsam (ex , locals (), "plan" , "solution" , "outputs" , network = "net" )
300-
301- def __call__ (
302- self , named_inputs , outputs = None , method = None , overwrites_collector = None
303- ):
304- return self .compute (named_inputs , outputs = outputs )
305-
306- def set_execution_method (self , method ):
307- """
308- Determine how the network will be executed.
309-
310- :param str method:
311- If "parallel", execute graph operations concurrently
312- using a threadpool.
250+ an optional name list, any nodes in there are plotted
251+ as an "inverted-house"
252+ :param solution:
253+ an optional dict with values to annotate nodes, drawn "filled"
254+ (currently content not shown, but node drawn as "filled")
255+ :param executed:
256+ an optional container with operations executed, drawn "filled"
257+ :param title:
258+ an optional string to display at the bottom of the graph
259+ :param node_props:
260+ an optional nested dict of Grapvhiz attributes for certain nodes
261+ :param edge_props:
262+ an optional nested dict of Grapvhiz attributes for certain edges
263+ :param clusters:
264+ an optional mapping of nodes --> cluster-names, to group them
265+
266+ :return:
267+ A ``pydot.Dot`` instance.
268+ NOTE that the returned instance is monkeypatched to support
269+ direct rendering in *jupyter cells* as SVG.
270+
271+
272+ Note that the `graph` argument is absent - Each Plotter provides
273+ its own graph internally; use directly :func:`render_pydot()` to provide
274+ a different graph.
275+
276+ .. image:: images/GraphtikLegend.svg
277+ :alt: Graphtik Legend
278+
279+ *NODES:*
280+
281+ oval
282+ function
283+ egg
284+ subgraph operation
285+ house
286+ given input
287+ inversed-house
288+ asked output
289+ polygon
290+ given both as input & asked as output (what?)
291+ square
292+ intermediate data, neither given nor asked.
293+ red frame
294+ evict-instruction, to free up memory.
295+ blue frame
296+ pinned-instruction, not to overwrite intermediate inputs.
297+ filled
298+ data node has a value in `solution` OR function has been executed.
299+ thick frame
300+ function/data node in execution `steps`.
301+
302+ *ARROWS*
303+
304+ solid black arrows
305+ dependencies (source-data *need*-ed by target-operations,
306+ sources-operations *provides* target-data)
307+ dashed black arrows
308+ optional needs
309+ blue arrows
310+ sideffect needs/provides
311+ wheat arrows
312+ broken dependency (``provide``) during pruning
313+ green-dotted arrows
314+ execution steps labeled in succession
315+
316+
317+ To generate the **legend**, see :func:`legend()`.
318+
319+ **Sample code:**
320+
321+ >>> from graphtik import compose, operation
322+ >>> from graphtik.modifiers import optional
323+ >>> from operator import add
324+
325+ >>> graphop = compose(name="graphop")(
326+ ... operation(name="add", needs=["a", "b1"], provides=["ab1"])(add),
327+ ... operation(name="sub", needs=["a", optional("b2")], provides=["ab2"])(lambda a, b=1: a-b),
328+ ... operation(name="abb", needs=["ab1", "ab2"], provides=["asked"])(add),
329+ ... )
330+
331+ >>> graphop.plot(show=True); # plot just the graph in a matplotlib window # doctest: +SKIP
332+ >>> inputs = {'a': 1, 'b1': 2}
333+ >>> solution = graphop(inputs) # now plots will include the execution-plan
334+
335+ >>> graphop.plot('plot1.svg', inputs=inputs, outputs=['asked', 'b1'], solution=solution); # doctest: +SKIP
336+ >>> dot = graphop.plot(solution=solution); # just get the `pydoit.Dot` object, renderable in Jupyter
337+ >>> print(dot)
338+ digraph G {
339+ fontname=italic;
340+ label=graphop;
341+ a [fillcolor=wheat, shape=invhouse, style=filled];
342+ ...
343+ ...
313344 """
314- choices = ["parallel" , "sequential" ]
315- if method not in choices :
316- raise ValueError (
317- "Invalid computation method %r! Must be one of %s" % (method , choices )
318- )
319- self .execution_method = method
320-
321- def set_overwrites_collector (self , collector ):
322- """
323- Asks to put all *overwrites* into the `collector` after computing
345+ from .plot import render_pydot
324346
325- An "overwrites" is intermediate value calculated but NOT stored
326- into the results, becaues it has been given also as an intemediate
327- input value, and the operation that would overwrite it MUST run for
328- its other results.
347+ dot = self ._build_pydot (** kws )
348+ return render_pydot (dot , filename = filename , show = show )
329349
330- :param collector:
331- a mutable dict to be fillwed with named values
332- """
333- if collector is not None and not isinstance (collector , abc .MutableMapping ):
334- raise ValueError (
335- "Overwrites collector was not a MutableMapping, but: %r" % collector
336- )
337- self .overwrites_collector = collector
350+ @abc .abstractmethod
351+ def _build_pydot (self , ** kws ):
352+ pass
0 commit comments