source: orange/Orange/OrangeCanvas/scheme/scheme.py @ 11275:031a2d259b88

Revision 11275:031a2d259b88, 13.9 KB checked in by Ales Erjavec <ales.erjavec@…>, 15 months ago (diff)

Check the sink channel cardinality and link duplication when adding a new link.

Line 
1"""
2Scheme Workflow
3
4"""
5
6from operator import itemgetter
7from collections import deque
8
9import logging
10
11from PyQt4.QtCore import QObject
12from PyQt4.QtCore import pyqtSignal as Signal
13from PyQt4.QtCore import pyqtProperty as Property
14
15from .node import SchemeNode
16from .link import SchemeLink, compatible_channels
17from .annotations import BaseSchemeAnnotation
18
19from .utils import check_arg, check_type
20
21from .errors import (
22    SchemeCycleError, IncompatibleChannelTypeError, SinkChannelError,
23    DuplicatedLinkError
24)
25
26from .readwrite import scheme_to_ows_stream, parse_scheme
27
28from ..registry import WidgetDescription
29
30log = logging.getLogger(__name__)
31
32
33class Scheme(QObject):
34    """An QObject representing the scheme widget workflow
35    with annotations, etc.
36
37    """
38
39    node_added = Signal(SchemeNode)
40    node_removed = Signal(SchemeNode)
41
42    link_added = Signal(SchemeLink)
43    link_removed = Signal(SchemeLink)
44
45    topology_changed = Signal()
46
47    node_state_changed = Signal()
48    channel_state_changed = Signal()
49
50    annotation_added = Signal(BaseSchemeAnnotation)
51    annotation_removed = Signal(BaseSchemeAnnotation)
52
53    node_property_changed = Signal(SchemeNode, str, object)
54
55    title_changed = Signal(unicode)
56    description_changed = Signal(unicode)
57
58    def __init__(self, parent=None, title=None, description=None):
59        QObject.__init__(self, parent)
60
61        self.__title = title or ""
62        "Scheme title (empty string by default)."
63
64        self.__description = description or ""
65        "Scheme description (empty string by default)."
66
67        self.__annotations = []
68        self.__nodes = []
69        self.__links = []
70
71    @property
72    def nodes(self):
73        return list(self.__nodes)
74
75    @property
76    def links(self):
77        return list(self.__links)
78
79    @property
80    def annotations(self):
81        return list(self.__annotations)
82
83    def set_title(self, title):
84        if self.__title != title:
85            self.__title = title
86            self.title_changed.emit(title)
87
88    def title(self):
89        return self.__title
90
91    title = Property(unicode, fget=title, fset=set_title)
92
93    def set_description(self, description):
94        if self.__description != description:
95            self.__description = description
96            self.description_changed.emit(description)
97
98    def description(self):
99        return self.__description
100
101    description = Property(unicode, fget=description, fset=set_description)
102
103    def add_node(self, node):
104        """Add a node to the scheme.
105
106        Parameters
107        ----------
108        node : `SchemeNode`
109            Node to add to the scheme.
110
111        """
112        check_arg(node not in self.__nodes,
113                  "Node already in scheme.")
114        check_type(node, SchemeNode)
115
116        self.__nodes.append(node)
117        log.info("Added node %r to scheme %r." % (node.title, self.title))
118        self.node_added.emit(node)
119
120    def new_node(self, description, title=None, position=None,
121                 properties=None):
122        """Create a new SchemeNode and add it to the scheme.
123        Same as:
124
125            scheme.add_node(SchemeNode(description, title, position,
126                                       properties))
127
128        """
129        if isinstance(description, WidgetDescription):
130            node = SchemeNode(description, title=title, position=position,
131                              properties=properties)
132        else:
133            raise TypeError("Expected %r, got %r." % \
134                            (WidgetDescription, type(description)))
135
136        self.add_node(node)
137        return node
138
139    def remove_node(self, node):
140        """Remove a `node` from the scheme. All links into and out of the node
141        are also removed.
142
143        """
144        check_arg(node in self.__nodes,
145                  "Node is not in the scheme.")
146
147        self.__remove_node_links(node)
148        self.__nodes.remove(node)
149        log.info("Removed node %r from scheme %r." % (node.title, self.title))
150        self.node_removed.emit(node)
151        return node
152
153    def __remove_node_links(self, node):
154        """Remove all links for node.
155        """
156        links_in, links_out = [], []
157        for link in self.__links:
158            if link.source_node is node:
159                links_out.append(link)
160            elif link.sink_node is node:
161                links_in.append(link)
162
163        for link in links_out + links_in:
164            self.remove_link(link)
165
166    def add_link(self, link):
167        """
168        Add a link to the scheme.
169        """
170        check_type(link, SchemeLink)
171
172        self.check_connect(link)
173        self.__links.append(link)
174
175        log.info("Added link %r (%r) -> %r (%r) to scheme %r." % \
176                 (link.source_node.title, link.source_channel.name,
177                  link.sink_node.title, link.sink_channel.name,
178                  self.title)
179                 )
180
181        self.link_added.emit(link)
182
183    def new_link(self, source_node, source_channel,
184                 sink_node, sink_channel):
185        """
186        Create a new SchemeLink and add it to the scheme.
187
188        Same as:
189
190            scheme.add_link(SchemeLink(source_node, source_channel,
191                                       sink_node, sink_channel)
192
193        """
194        link = SchemeLink(source_node, source_channel,
195                          sink_node, sink_channel)
196        self.add_link(link)
197        return link
198
199    def remove_link(self, link):
200        """Remove a link from the scheme.
201        """
202        check_arg(link in self.__links,
203                  "Link is not in the scheme.")
204
205        self.__links.remove(link)
206        log.info("Removed link %r (%r) -> %r (%r) from scheme %r." % \
207                 (link.source_node.title, link.source_channel.name,
208                  link.sink_node.title, link.sink_channel.name,
209                  self.title)
210                 )
211        self.link_removed.emit(link)
212
213    def check_connect(self, link):
214        """
215        Check if the link can be added to the scheme.
216
217        Can raise:
218            - `TypeError` if link is not an instance of :class:`SchemeLink`.
219            - `SchemeCycleError` if the link would introduce a cycle
220            - `IncompatibleChannelTypeError` if the channel types are not
221               compatible
222            - `SinkChannelError` if a sink channel has a `Single` flag
223               specification and there is already connected.
224            - `DuplicatedLinkError` if a link duplicates an already present
225               link.
226
227        """
228        check_type(link, SchemeLink)
229
230        if self.creates_cycle(link):
231            raise SchemeCycleError("Cannot create cycles in the scheme")
232
233        if not self.compatible_channels(link):
234            raise IncompatibleChannelTypeError(
235                    "Cannot connect %r to %r." \
236                    % (link.source_channel.type, link.sink_channel.type)
237                )
238
239        links = self.find_links(source_node=link.source_node,
240                                source_channel=link.source_channel,
241                                sink_node=link.sink_node,
242                                sink_channel=link.sink_channel)
243
244        if links:
245            raise DuplicatedLinkError(
246                    "A link from %r (%r) -> %r (%r) already exists" \
247                    % (link.source_node.title, link.source_channel.name,
248                       link.sink_node.title, link.sink_channel.name)
249                )
250
251        if link.sink_channel.single:
252            links = self.find_links(sink_node=link.sink_node,
253                                    sink_channel=link.sink_channel)
254            if links:
255                raise SinkChannelError(
256                        "%r is already connected." % link.sink_channel.name
257                    )
258
259    def creates_cycle(self, link):
260        """Would the `link` if added to the scheme introduce a cycle.
261        """
262        check_type(link, SchemeLink)
263        source_node, sink_node = link.source_node, link.sink_node
264        upstream = self.upstream_nodes(source_node)
265        upstream.add(source_node)
266        return sink_node in upstream
267
268    def compatible_channels(self, link):
269        """Do the channels in link have compatible types.
270        """
271        check_type(link, SchemeLink)
272        return compatible_channels(link.source_channel, link.sink_channel)
273
274    def can_connect(self, link):
275        try:
276            self.check_connect(link)
277            return True
278        except (SchemeCycleError, IncompatibleChannelTypeError):
279            return False
280        except Exception:
281            raise
282
283    def upstream_nodes(self, start_node):
284        """Return a set of all nodes upstream from `start_node`.
285        """
286        visited = set()
287        queue = deque([start_node])
288        while queue:
289            node = queue.popleft()
290            snodes = [link.source_node for link in self.input_links(node)]
291            for source_node in snodes:
292                if source_node not in visited:
293                    queue.append(source_node)
294
295            visited.add(node)
296        visited.remove(start_node)
297        return visited
298
299    def downstream_nodes(self, start_node):
300        """Return a set of all nodes downstream from `start_node`.
301        """
302        visited = set()
303        queue = deque([start_node])
304        while queue:
305            node = queue.popleft()
306            snodes = [link.sink_node for link in self.output_links(node)]
307            for source_node in snodes:
308                if source_node not in visited:
309                    queue.append(source_node)
310
311            visited.add(node)
312        visited.remove(start_node)
313        return visited
314
315    def is_ancestor(self, node, child):
316        """Return True if `node` is an ancestor node of `child` (is upstream
317        of the child in the workflow). Both nodes must be in the scheme.
318
319        """
320        return child in self.downstream_nodes(node)
321
322    def children(self, node):
323        """Return a set of all children of `node`.
324        """
325        return set(link.sink_node for link in self.output_links(node))
326
327    def parents(self, node):
328        """Return a set if all parents of `node`.
329        """
330        return set(link.source_node for link in self.input_links(node))
331
332    def input_links(self, node):
333        """Return all input links connected to the `node`.
334        """
335        return self.find_links(sink_node=node)
336
337    def output_links(self, node):
338        """Return all output links connected to the `node`.
339        """
340        return self.find_links(source_node=node)
341
342    def find_links(self, source_node=None, source_channel=None,
343                   sink_node=None, sink_channel=None):
344        # TODO: Speedup - keep index of links by nodes and channels
345        result = []
346        match = lambda query, value: (query is None or value == query)
347        for link in self.__links:
348            if match(source_node, link.source_node) and \
349                    match(sink_node, link.sink_node) and \
350                    match(source_channel, link.source_channel) and \
351                    match(sink_channel, link.sink_channel):
352                result.append(link)
353
354        return result
355
356    def propose_links(self, source_node, sink_node):
357        """Return a list of ordered (`OutputSignal`, `InputSignal`, weight)
358        tuples that could be added to the scheme between `source_node` and
359        `sink_node`.
360
361        .. note:: This can depend on the links already in the scheme.
362
363        """
364        if source_node is sink_node or \
365                self.is_ancestor(sink_node, source_node):
366            # Cyclic connections are not possible.
367            return []
368
369        outputs = source_node.output_channels()
370        inputs = sink_node.input_channels()
371
372        # Get existing links to sink channels that are Single.
373        links = self.find_links(None, None, sink_node)
374        already_connected_sinks = [link.sink_channel for link in links \
375                                   if link.sink_channel.single]
376
377        def weight(out_c, in_c):
378            if out_c.explicit or in_c.explicit:
379                # Zero weight for explicit links
380                weight = 0
381            else:
382                check = [not out_c.dynamic,  # Dynamic signals are last
383                         in_c not in already_connected_sinks,
384                         bool(in_c.default),
385                         bool(out_c.default)
386                         ]
387                weights = [2 ** i for i in range(len(check), 0, -1)]
388                weight = sum([w for w, c in zip(weights, check) if c])
389            return weight
390
391        proposed_links = []
392        for out_c in outputs:
393            for in_c in inputs:
394                if compatible_channels(out_c, in_c):
395                    proposed_links.append((out_c, in_c, weight(out_c, in_c)))
396
397        return sorted(proposed_links, key=itemgetter(-1), reverse=True)
398
399    def add_annotation(self, annotation):
400        """Add an annotation (`BaseSchemeAnnotation`) subclass to the scheme.
401
402        """
403        check_arg(annotation not in self.__annotations,
404                  "Cannot add the same annotation multiple times.")
405        check_type(annotation, BaseSchemeAnnotation)
406
407        self.__annotations.append(annotation)
408        self.annotation_added.emit(annotation)
409
410    def remove_annotation(self, annotation):
411        check_arg(annotation in self.__annotations,
412                  "Annotation is not in the scheme.")
413        self.__annotations.remove(annotation)
414        self.annotation_removed.emit(annotation)
415
416    def save_to(self, stream, pretty=True):
417        """Save the scheme as an xml formated file to `stream`
418        """
419        if isinstance(stream, basestring):
420            stream = open(stream, "wb")
421
422        scheme_to_ows_stream(self, stream, pretty)
423
424    def load_from(self, stream):
425        """Load the scheme from xml formated stream.
426        """
427        if self.__nodes or self.__links or self.__annotations:
428            # TODO: should we clear the scheme and load it.
429            raise ValueError("Scheme is not empty.")
430
431        if isinstance(stream, basestring):
432            stream = open(stream, "rb")
433
434        parse_scheme(self, stream)
Note: See TracBrowser for help on using the repository browser.