source: orange/Orange/OrangeCanvas/scheme/scheme.py @ 11391:6b2507ba9677

Revision 11391:6b2507ba9677, 18.1 KB checked in by Ales Erjavec <ales.erjavec@…>, 13 months ago (diff)

When possible, node properties are now saved as python literal strings.

Line 
1"""
2===============
3Scheme Workflow
4===============
5
6The :class:`Scheme` class defines a DAG (Directed Acyclic Graph) workflow.
7
8"""
9
10from operator import itemgetter
11from collections import deque
12
13import logging
14
15from PyQt4.QtCore import QObject
16from PyQt4.QtCore import pyqtSignal as Signal
17from PyQt4.QtCore import pyqtProperty as Property
18
19from .node import SchemeNode
20from .link import SchemeLink, compatible_channels
21from .annotations import BaseSchemeAnnotation
22
23from .utils import check_arg, check_type
24
25from .errors import (
26    SchemeCycleError, IncompatibleChannelTypeError, SinkChannelError,
27    DuplicatedLinkError
28)
29
30from .readwrite import scheme_to_ows_stream, parse_scheme
31
32from ..registry import WidgetDescription
33
34log = logging.getLogger(__name__)
35
36
37class Scheme(QObject):
38    """
39    An :class:`QObject` subclass representing the scheme widget workflow
40    with annotations.
41
42    Parameters
43    ----------
44    parent : :class:`QObject`
45        A parent QObject item (default `None`).
46    title : str
47        The scheme title.
48    description : str
49        A longer description of the scheme.
50
51
52    Attributes
53    ----------
54    nodes : list of :class:`.SchemeNode`
55        A list of all the nodes in the scheme.
56
57    links : list of :class:`.SchemeLink`
58        A list of all links in the scheme.
59
60    annotations : list of :class:`BaseSchemeAnnotation`
61        A list of all the annotations in the scheme.
62
63    """
64
65    # Signal emitted when a `node` is added to the scheme.
66    node_added = Signal(SchemeNode)
67
68    # Signal emitted when a `node` is removed from the scheme.
69    node_removed = Signal(SchemeNode)
70
71    # Signal emitted when a `link` is added to the scheme.
72    link_added = Signal(SchemeLink)
73
74    # Signal emitted when a `link` is removed from the scheme.
75    link_removed = Signal(SchemeLink)
76
77    # Signal emitted when a `annotation` is added to the scheme.
78    annotation_added = Signal(BaseSchemeAnnotation)
79
80    # Signal emitted when a `annotation` is removed from the scheme.
81    annotation_removed = Signal(BaseSchemeAnnotation)
82
83    # Signal emitted when the title of scheme changes.
84    title_changed = Signal(unicode)
85
86    # Signal emitted when the description of scheme changes.
87    description_changed = Signal(unicode)
88
89    node_state_changed = Signal()
90    channel_state_changed = Signal()
91    topology_changed = Signal()
92
93    def __init__(self, parent=None, title=None, description=None):
94        QObject.__init__(self, parent)
95
96        self.__title = title or ""
97        "Scheme title (empty string by default)."
98
99        self.__description = description or ""
100        "Scheme description (empty string by default)."
101
102        self.__annotations = []
103        self.__nodes = []
104        self.__links = []
105
106    @property
107    def nodes(self):
108        """
109        A list of all nodes (:class:`.SchemeNode`) currently in the scheme.
110        """
111        return list(self.__nodes)
112
113    @property
114    def links(self):
115        """
116        A list of all links (:class:`.SchemeLink`) currently in the scheme.
117        """
118        return list(self.__links)
119
120    @property
121    def annotations(self):
122        """
123        A list of all annotations (:class:`.BaseSchemeAnnotation`) in the
124        scheme.
125
126        """
127        return list(self.__annotations)
128
129    def set_title(self, title):
130        """
131        Set the scheme title text.
132        """
133        if self.__title != title:
134            self.__title = title
135            self.title_changed.emit(title)
136
137    def title(self):
138        """
139        The title (human readable string) of the scheme.
140        """
141        return self.__title
142
143    title = Property(unicode, fget=title, fset=set_title)
144
145    def set_description(self, description):
146        """
147        Set the scheme description text.
148        """
149        if self.__description != description:
150            self.__description = description
151            self.description_changed.emit(description)
152
153    def description(self):
154        """
155        Scheme description text.
156        """
157        return self.__description
158
159    description = Property(unicode, fget=description, fset=set_description)
160
161    def add_node(self, node):
162        """
163        Add a node to the scheme. An error is raised if the node is
164        already in the scheme.
165
166        Parameters
167        ----------
168        node : :class:`.SchemeNode`
169            Node instance to add to the scheme.
170
171        """
172        check_arg(node not in self.__nodes,
173                  "Node already in scheme.")
174        check_type(node, SchemeNode)
175
176        self.__nodes.append(node)
177        log.info("Added node %r to scheme %r." % (node.title, self.title))
178        self.node_added.emit(node)
179
180    def new_node(self, description, title=None, position=None,
181                 properties=None):
182        """
183        Create a new :class:`.SchemeNode` and add it to the scheme.
184
185        Same as::
186
187            scheme.add_node(SchemeNode(description, title, position,
188                                       properties))
189
190        Parameters
191        ----------
192        description : :class:`WidgetDescription`
193            The new node's description.
194        title : str, optional
195            Optional new nodes title. By default `description.name` is used.
196        position : `(x, y)` tuple of floats, optional
197            Optional position in a 2D space.
198        properties : dict, optional
199            A dictionary of optional extra properties.
200
201        See also
202        --------
203        SchemeNode, Scheme.add_node
204
205        """
206        if isinstance(description, WidgetDescription):
207            node = SchemeNode(description, title=title, position=position,
208                              properties=properties)
209        else:
210            raise TypeError("Expected %r, got %r." % \
211                            (WidgetDescription, type(description)))
212
213        self.add_node(node)
214        return node
215
216    def remove_node(self, node):
217        """
218        Remove a `node` from the scheme. All links into and out of the
219        `node` are also removed. If the node in not in the scheme an error
220        is raised.
221
222        Parameters
223        ----------
224        node : :class:`.SchemeNode`
225            Node instance to remove.
226
227        """
228        check_arg(node in self.__nodes,
229                  "Node is not in the scheme.")
230
231        self.__remove_node_links(node)
232        self.__nodes.remove(node)
233        log.info("Removed node %r from scheme %r." % (node.title, self.title))
234        self.node_removed.emit(node)
235        return node
236
237    def __remove_node_links(self, node):
238        """
239        Remove all links for node.
240        """
241        links_in, links_out = [], []
242        for link in self.__links:
243            if link.source_node is node:
244                links_out.append(link)
245            elif link.sink_node is node:
246                links_in.append(link)
247
248        for link in links_out + links_in:
249            self.remove_link(link)
250
251    def add_link(self, link):
252        """
253        Add a `link` to the scheme.
254
255        Parameters
256        ----------
257        link : :class:`.SchemeLink`
258            An initialized link instance to add to the scheme.
259
260        """
261        check_type(link, SchemeLink)
262
263        self.check_connect(link)
264        self.__links.append(link)
265
266        log.info("Added link %r (%r) -> %r (%r) to scheme %r." % \
267                 (link.source_node.title, link.source_channel.name,
268                  link.sink_node.title, link.sink_channel.name,
269                  self.title)
270                 )
271
272        self.link_added.emit(link)
273
274    def new_link(self, source_node, source_channel,
275                 sink_node, sink_channel):
276        """
277        Create a new SchemeLink and add it to the scheme.
278
279        Same as::
280
281            scheme.add_link(SchemeLink(source_node, source_channel,
282                                       sink_node, sink_channel))
283
284        Parameters
285        ----------
286        source_node : :class:`.SchemeNode`
287            Source node of the new link.
288        source_channel : :class:`OutputSignal`
289            Source channel of the new node. The instance must be from
290            `source_node.output_channels()`
291        sink_node : :class:`.SchemeNode`
292            Sink node of the new link.
293        sink_channel : :class:`InputSignal`
294            Sink channel of the new node. The instance must be from
295            `sink_node.input_channels()`
296
297        See also
298        --------
299        SchemeLink, Scheme.add_link
300
301        """
302        link = SchemeLink(source_node, source_channel,
303                          sink_node, sink_channel)
304        self.add_link(link)
305        return link
306
307    def remove_link(self, link):
308        """
309        Remove a link from the scheme.
310
311        Parameters
312        ----------
313        link : :class:`.SchemeLink`
314            Link instance to remove.
315
316        """
317        check_arg(link in self.__links,
318                  "Link is not in the scheme.")
319
320        self.__links.remove(link)
321        log.info("Removed link %r (%r) -> %r (%r) from scheme %r." % \
322                 (link.source_node.title, link.source_channel.name,
323                  link.sink_node.title, link.sink_channel.name,
324                  self.title)
325                 )
326        self.link_removed.emit(link)
327
328    def check_connect(self, link):
329        """
330        Check if the `link` can be added to the scheme.
331
332        Can raise:
333            - :class:`TypeError` if `link` is not an instance of
334              :class:`.SchemeLink`
335            - :class:`SchemeCycleError` if the `link` would introduce a cycle
336            - :class:`IncompatibleChannelTypeError` if the channel types are
337              not compatible
338            - :class:`SinkChannelError` if a sink channel has a `Single` flag
339              specification and the channel is already connected.
340            - :class:`DuplicatedLinkError` if a `link` duplicates an already
341              present link.
342
343        """
344        check_type(link, SchemeLink)
345
346        if self.creates_cycle(link):
347            raise SchemeCycleError("Cannot create cycles in the scheme")
348
349        if not self.compatible_channels(link):
350            raise IncompatibleChannelTypeError(
351                    "Cannot connect %r to %r." \
352                    % (link.source_channel.type, link.sink_channel.type)
353                )
354
355        links = self.find_links(source_node=link.source_node,
356                                source_channel=link.source_channel,
357                                sink_node=link.sink_node,
358                                sink_channel=link.sink_channel)
359
360        if links:
361            raise DuplicatedLinkError(
362                    "A link from %r (%r) -> %r (%r) already exists" \
363                    % (link.source_node.title, link.source_channel.name,
364                       link.sink_node.title, link.sink_channel.name)
365                )
366
367        if link.sink_channel.single:
368            links = self.find_links(sink_node=link.sink_node,
369                                    sink_channel=link.sink_channel)
370            if links:
371                raise SinkChannelError(
372                        "%r is already connected." % link.sink_channel.name
373                    )
374
375    def creates_cycle(self, link):
376        """
377        Would the `link` if added to the scheme introduce a cycle.
378        """
379        check_type(link, SchemeLink)
380        source_node, sink_node = link.source_node, link.sink_node
381        upstream = self.upstream_nodes(source_node)
382        upstream.add(source_node)
383        return sink_node in upstream
384
385    def compatible_channels(self, link):
386        """
387        Do the channels in `link` have compatible types.
388        """
389        check_type(link, SchemeLink)
390        return compatible_channels(link.source_channel, link.sink_channel)
391
392    def can_connect(self, link):
393        try:
394            self.check_connect(link)
395            return True
396        except (SchemeCycleError, IncompatibleChannelTypeError):
397            return False
398        except Exception:
399            raise
400
401    def upstream_nodes(self, start_node):
402        """
403        Return a set of all nodes upstream from `start_node`.
404        """
405        visited = set()
406        queue = deque([start_node])
407        while queue:
408            node = queue.popleft()
409            snodes = [link.source_node for link in self.input_links(node)]
410            for source_node in snodes:
411                if source_node not in visited:
412                    queue.append(source_node)
413
414            visited.add(node)
415        visited.remove(start_node)
416        return visited
417
418    def downstream_nodes(self, start_node):
419        """
420        Return a set of all nodes downstream from `start_node`.
421        """
422        visited = set()
423        queue = deque([start_node])
424        while queue:
425            node = queue.popleft()
426            snodes = [link.sink_node for link in self.output_links(node)]
427            for source_node in snodes:
428                if source_node not in visited:
429                    queue.append(source_node)
430
431            visited.add(node)
432        visited.remove(start_node)
433        return visited
434
435    def is_ancestor(self, node, child):
436        """
437        Return True if `node` is an ancestor node of `child` (is upstream
438        of the child in the workflow). Both nodes must be in the scheme.
439
440        """
441        return child in self.downstream_nodes(node)
442
443    def children(self, node):
444        """
445        Return a set of all children of `node`.
446        """
447        return set(link.sink_node for link in self.output_links(node))
448
449    def parents(self, node):
450        """
451        Return a set of all parents of `node`.
452        """
453        return set(link.source_node for link in self.input_links(node))
454
455    def input_links(self, node):
456        """
457        Return all input links connected to the `node` instance.
458        """
459        return self.find_links(sink_node=node)
460
461    def output_links(self, node):
462        """
463        Return all output links connected to the `node` instance.
464        """
465        return self.find_links(source_node=node)
466
467    def find_links(self, source_node=None, source_channel=None,
468                   sink_node=None, sink_channel=None):
469        # TODO: Speedup - keep index of links by nodes and channels
470        result = []
471        match = lambda query, value: (query is None or value == query)
472        for link in self.__links:
473            if match(source_node, link.source_node) and \
474                    match(sink_node, link.sink_node) and \
475                    match(source_channel, link.source_channel) and \
476                    match(sink_channel, link.sink_channel):
477                result.append(link)
478
479        return result
480
481    def propose_links(self, source_node, sink_node):
482        """
483        Return a list of ordered (:class:`OutputSignal`,
484        :class:`InputSignal`, weight) tuples that could be added to
485        the scheme between `source_node` and `sink_node`.
486
487        .. note:: This can depend on the links already in the scheme.
488
489        """
490        if source_node is sink_node or \
491                self.is_ancestor(sink_node, source_node):
492            # Cyclic connections are not possible.
493            return []
494
495        outputs = source_node.output_channels()
496        inputs = sink_node.input_channels()
497
498        # Get existing links to sink channels that are Single.
499        links = self.find_links(None, None, sink_node)
500        already_connected_sinks = [link.sink_channel for link in links \
501                                   if link.sink_channel.single]
502
503        def weight(out_c, in_c):
504            if out_c.explicit or in_c.explicit:
505                # Zero weight for explicit links
506                weight = 0
507            else:
508                check = [not out_c.dynamic,  # Dynamic signals are last
509                         in_c not in already_connected_sinks,
510                         bool(in_c.default),
511                         bool(out_c.default)
512                         ]
513                weights = [2 ** i for i in range(len(check), 0, -1)]
514                weight = sum([w for w, c in zip(weights, check) if c])
515            return weight
516
517        proposed_links = []
518        for out_c in outputs:
519            for in_c in inputs:
520                if compatible_channels(out_c, in_c):
521                    proposed_links.append((out_c, in_c, weight(out_c, in_c)))
522
523        return sorted(proposed_links, key=itemgetter(-1), reverse=True)
524
525    def add_annotation(self, annotation):
526        """
527        Add an annotation (:class:`BaseSchemeAnnotation` subclass) instance
528        to the scheme.
529
530        """
531        check_arg(annotation not in self.__annotations,
532                  "Cannot add the same annotation multiple times.")
533        check_type(annotation, BaseSchemeAnnotation)
534
535        self.__annotations.append(annotation)
536        self.annotation_added.emit(annotation)
537
538    def remove_annotation(self, annotation):
539        """
540        Remove the `annotation` instance from the scheme.
541        """
542        check_arg(annotation in self.__annotations,
543                  "Annotation is not in the scheme.")
544        self.__annotations.remove(annotation)
545        self.annotation_removed.emit(annotation)
546
547    def clear(self):
548        """
549        Remove all nodes, links, and annotation items from the scheme.
550        """
551        def is_terminal(node):
552            return not bool(self.find_links(source_node=node))
553
554        while self.nodes:
555            terminal_nodes = filter(is_terminal, self.nodes)
556            for node in terminal_nodes:
557                self.remove_node(node)
558
559        for annotation in self.annotations:
560            self.remove_annotation(annotation)
561
562        assert(not (self.nodes or self.links or self.annotations))
563
564    def save_to(self, stream, pretty=True, pickle_fallback=False):
565        """
566        Save the scheme as an xml formated file to `stream`
567
568        See also
569        --------
570        .scheme_to_ows_stream
571
572        """
573        if isinstance(stream, basestring):
574            stream = open(stream, "wb")
575
576        scheme_to_ows_stream(self, stream, pretty,
577                             pickle_fallback=pickle_fallback)
578
579    def load_from(self, stream):
580        """
581        Load the scheme from xml formated stream.
582        """
583        if self.__nodes or self.__links or self.__annotations:
584            # TODO: should we clear the scheme and load it.
585            raise ValueError("Scheme is not empty.")
586
587        if isinstance(stream, basestring):
588            stream = open(stream, "rb")
589
590        parse_scheme(self, stream)
Note: See TracBrowser for help on using the repository browser.