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

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

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

Line 
1"""
2Scheme save/load routines.
3
4"""
5import sys
6
7from xml.etree.ElementTree import TreeBuilder, Element, ElementTree, parse
8
9from collections import defaultdict
10from itertools import chain
11
12import cPickle as pickle
13import json
14import pprint
15
16import ast
17from ast import literal_eval
18
19import logging
20
21from . import SchemeNode, SchemeLink
22from .annotations import SchemeTextAnnotation, SchemeArrowAnnotation
23from .errors import IncompatibleChannelTypeError
24
25from .. import registry
26
27log = logging.getLogger(__name__)
28
29
30class UnknownWidgetDefinition(Exception):
31    pass
32
33
34def string_eval(source):
35    """
36    Evaluate a python string literal `source`. Raise ValueError if
37    `source` is not a string literal.
38
39    >>> string_eval("'a string'")
40    a string
41
42    """
43    node = ast.parse(source, "<source>", mode="eval")
44    if not isinstance(node.body, ast.Str):
45        raise ValueError("%r is not a string literal" % source)
46    return node.body.s
47
48
49def tuple_eval(source):
50    """
51    Evaluate a python tuple literal `source` where the elements are
52    constrained to be int, float or string. Raise ValueError if not
53    a tuple literal.
54
55    >>> tuple_eval("(1, 2, "3")")
56    (1, 2, '3')
57
58    """
59    node = ast.parse(source, "<source>", mode="eval")
60
61    if not isinstance(node.body, ast.Tuple):
62        raise ValueError("%r is not a tuple literal" % source)
63
64    if not all(isinstance(el, (ast.Str, ast.Num))
65               for el in node.body.elts):
66        raise ValueError("Can only contain numbers or strings")
67
68    return literal_eval(source)
69
70
71def terminal_eval(source):
72    """
73    Evaluate a python 'constant' (string, number, None, True, False)
74    `source`. Raise ValueError is not a terminal literal.
75
76    >>> terminal_eval("True")
77    True
78
79    """
80    node = ast.parse(source, "<source>", mode="eval")
81
82    try:
83        return _terminal_value(node.body)
84    except ValueError:
85        raise
86        raise ValueError("%r is not a terminal constant" % source)
87
88
89def _terminal_value(node):
90    if isinstance(node, ast.Str):
91        return node.s
92    elif isinstance(node, ast.Num):
93        return node.n
94    elif isinstance(node, ast.Name) and \
95            node.id in ["True", "False", "None"]:
96        return __builtins__[node.id]
97
98    raise ValueError("Not a terminal")
99
100
101def sniff_version(stream):
102    """
103    Parse a scheme stream and return the scheme's serialization
104    version string.
105
106    """
107    doc = parse(stream)
108    scheme_el = doc.getroot()
109    version = scheme_el.attrib.get("version", None)
110    # Fallback: check for "widgets" tag.
111    if scheme_el.find("widgets") is not None:
112        version = "1.0"
113    else:
114        version = "2.0"
115
116    return version
117
118
119def parse_scheme(scheme, stream, error_handler=None,
120                 allow_pickle_data=False):
121    """
122    Parse a saved scheme from `stream` and populate a `scheme`
123    instance (:class:`Scheme`).
124    `error_handler` if given will be called with an exception when
125    a 'recoverable' error occurs. By default the exception is simply
126    raised.
127
128    Parameters
129    ----------
130    scheme : :class:`.Scheme`
131        A scheme instance to populate with the contents of `stream`.
132    stream : file-like object
133        A file like object opened for reading.
134    error_hander : function, optional
135        A function to call with an exception instance when a `recoverable`
136        error occurs.
137    allow_picked_data : bool, optional
138        Specifically allow parsing of picked data streams.
139
140    """
141    doc = parse(stream)
142    scheme_el = doc.getroot()
143    version = scheme_el.attrib.get("version", None)
144    if version is None:
145        # Fallback: check for "widgets" tag.
146        if scheme_el.find("widgets") is not None:
147            version = "1.0"
148        else:
149            version = "2.0"
150
151    if error_handler is None:
152        def error_handler(exc):
153            raise exc
154
155    if version == "1.0":
156        parse_scheme_v_1_0(doc, scheme, error_handler=error_handler,
157                           allow_pickle_data=allow_pickle_data)
158        return scheme
159    else:
160        parse_scheme_v_2_0(doc, scheme, error_handler=error_handler,
161                           allow_pickle_data=allow_pickle_data)
162        return scheme
163
164
165def scheme_node_from_element(node_el, registry):
166    """
167    Create a SchemeNode from an `Element` instance.
168    """
169    try:
170        widget_desc = registry.widget(node_el.get("qualified_name"))
171    except KeyError as ex:
172        raise UnknownWidgetDefinition(*ex.args)
173
174    title = node_el.get("title")
175    pos = node_el.get("position")
176
177    if pos is not None:
178        pos = tuple_eval(pos)
179
180    return SchemeNode(widget_desc, title=title, position=pos)
181
182
183def parse_scheme_v_2_0(etree, scheme, error_handler, widget_registry=None,
184                       allow_pickle_data=False):
185    """
186    Parse an `ElementTree` instance.
187    """
188    if widget_registry is None:
189        widget_registry = registry.global_registry()
190
191    nodes_not_found = []
192
193    nodes = []
194    links = []
195
196    id_to_node = {}
197
198    scheme_node = etree.getroot()
199    scheme.title = scheme_node.attrib.get("title", "")
200    scheme.description = scheme_node.attrib.get("description", "")
201
202    # Load and create scheme nodes.
203    for node_el in etree.findall("nodes/node"):
204        try:
205            node = scheme_node_from_element(node_el, widget_registry)
206        except UnknownWidgetDefinition, ex:
207            # description was not found
208            error_handler(ex)
209            node = None
210        except Exception:
211            raise
212
213        if node is not None:
214            nodes.append(node)
215            id_to_node[node_el.get("id")] = node
216        else:
217            nodes_not_found.append(node_el.get("id"))
218
219    # Load and create scheme links.
220    for link_el in etree.findall("links/link"):
221        source_id = link_el.get("source_node_id")
222        sink_id = link_el.get("sink_node_id")
223
224        if source_id in nodes_not_found or sink_id in nodes_not_found:
225            continue
226
227        source = id_to_node.get(source_id)
228        sink = id_to_node.get(sink_id)
229
230        source_channel = link_el.get("source_channel")
231        sink_channel = link_el.get("sink_channel")
232        enabled = link_el.get("enabled") == "true"
233
234        try:
235            link = SchemeLink(source, source_channel, sink, sink_channel,
236                              enabled=enabled)
237        except (ValueError, IncompatibleChannelTypeError) as ex:
238            error_handler(ex)
239        else:
240            links.append(link)
241
242    # Load node properties
243    for property_el in etree.findall("node_properties/properties"):
244        node_id = property_el.attrib.get("node_id")
245
246        if node_id in nodes_not_found:
247            continue
248
249        node = id_to_node[node_id]
250
251        format = property_el.attrib.get("format", "pickle")
252
253        if "data" in property_el.attrib:
254            # data string is 'encoded' with 'repr' i.e. unicode and
255            # nonprintable characters are \u or \x escaped.
256            # Could use 'codecs' module?
257            data = string_eval(property_el.attrib.get("data"))
258        else:
259            data = property_el.text
260
261        properties = None
262        if format != "pickle" or allow_pickle_data:
263            try:
264                properties = loads(data, format)
265            except Exception:
266                log.error("Could not load properties for %r.", node.title,
267                          exc_info=True)
268
269        if properties is not None:
270            node.properties = properties
271
272    annotations = []
273    for annot_el in etree.findall("annotations/*"):
274        if annot_el.tag == "text":
275            rect = annot_el.attrib.get("rect", "(0, 0, 20, 20)")
276            rect = tuple_eval(rect)
277
278            font_family = annot_el.attrib.get("font-family", "").strip()
279            font_size = annot_el.attrib.get("font-size", "").strip()
280
281            font = {}
282            if font_family:
283                font["family"] = font_family
284            if font_size:
285                font["size"] = int(font_size)
286
287            annot = SchemeTextAnnotation(rect, annot_el.text or "", font=font)
288        elif annot_el.tag == "arrow":
289            start = annot_el.attrib.get("start", "(0, 0)")
290            end = annot_el.attrib.get("end", "(0, 0)")
291            start, end = map(tuple_eval, (start, end))
292
293            color = annot_el.attrib.get("fill", "red")
294            annot = SchemeArrowAnnotation(start, end, color=color)
295        annotations.append(annot)
296
297    for node in nodes:
298        scheme.add_node(node)
299
300    for link in links:
301        scheme.add_link(link)
302
303    for annot in annotations:
304        scheme.add_annotation(annot)
305
306
307def parse_scheme_v_1_0(etree, scheme, error_handler, widget_registry=None,
308                       allow_pickle_data=False):
309    """
310    ElementTree Instance of an old .ows scheme format.
311    """
312    if widget_registry is None:
313        widget_registry = registry.global_registry()
314
315    widgets_not_found = []
316
317    widgets = widget_registry.widgets()
318    widgets_by_name = [(d.qualified_name.rsplit(".", 1)[-1], d)
319                       for d in widgets]
320    widgets_by_name = dict(widgets_by_name)
321
322    nodes_by_caption = {}
323    nodes = []
324    links = []
325    for widget_el in etree.findall("widgets/widget"):
326        caption = widget_el.get("caption")
327        name = widget_el.get("widgetName")
328        x_pos = widget_el.get("xPos")
329        y_pos = widget_el.get("yPos")
330
331        if name in widgets_by_name:
332            desc = widgets_by_name[name]
333        else:
334            error_handler(UnknownWidgetDefinition(name))
335            widgets_not_found.append(caption)
336            continue
337
338        node = SchemeNode(desc, title=caption,
339                          position=(int(x_pos), int(y_pos)))
340        nodes_by_caption[caption] = node
341        nodes.append(node)
342
343    for channel_el in etree.findall("channels/channel"):
344        in_caption = channel_el.get("inWidgetCaption")
345        out_caption = channel_el.get("outWidgetCaption")
346
347        if in_caption in widgets_not_found or \
348                out_caption in widgets_not_found:
349            continue
350
351        source = nodes_by_caption[out_caption]
352        sink = nodes_by_caption[in_caption]
353        enabled = channel_el.get("enabled") == "1"
354        signals = literal_eval(channel_el.get("signals"))
355
356        for source_channel, sink_channel in signals:
357            try:
358                link = SchemeLink(source, source_channel, sink, sink_channel,
359                                  enabled=enabled)
360            except (ValueError, IncompatibleChannelTypeError) as ex:
361                error_handler(ex)
362            else:
363                links.append(link)
364
365    settings = etree.find("settings")
366    properties = {}
367    if settings is not None:
368        data = settings.attrib.get("settingsDictionary", None)
369        if data and allow_pickle_data:
370            try:
371                properties = literal_eval(data)
372            except Exception:
373                log.error("Could not load properties for the scheme.",
374                          exc_info=True)
375
376    for node in nodes:
377        if node.title in properties:
378            try:
379                node.properties = pickle.loads(properties[node.title])
380            except Exception:
381                log.error("Could not unpickle properties for the node %r.",
382                          node.title, exc_info=True)
383
384        scheme.add_node(node)
385
386    for link in links:
387        scheme.add_link(link)
388
389
390def inf_range(start=0, step=1):
391    """Return an infinite range iterator.
392    """
393    while True:
394        yield start
395        start += step
396
397
398def scheme_to_etree(scheme, data_format="literal", pickle_fallback=False):
399    """
400    Return an `xml.etree.ElementTree` representation of the `scheme.
401    """
402    builder = TreeBuilder(element_factory=Element)
403    builder.start("scheme", {"version": "2.0",
404                             "title": scheme.title or "",
405                             "description": scheme.description or ""})
406
407    ## Nodes
408    node_ids = defaultdict(inf_range().next)
409    builder.start("nodes", {})
410    for node in scheme.nodes:
411        desc = node.description
412        attrs = {"id": str(node_ids[node]),
413                 "name": desc.name,
414                 "qualified_name": desc.qualified_name,
415                 "project_name": desc.project_name or "",
416                 "version": desc.version or "",
417                 "title": node.title,
418                 }
419        if node.position is not None:
420            attrs["position"] = str(node.position)
421
422        if type(node) is not SchemeNode:
423            attrs["scheme_node_type"] = "%s.%s" % (type(node).__name__,
424                                                   type(node).__module__)
425        builder.start("node", attrs)
426        builder.end("node")
427
428    builder.end("nodes")
429
430    ## Links
431    link_ids = defaultdict(inf_range().next)
432    builder.start("links", {})
433    for link in scheme.links:
434        source = link.source_node
435        sink = link.sink_node
436        source_id = node_ids[source]
437        sink_id = node_ids[sink]
438        attrs = {"id": str(link_ids[link]),
439                 "source_node_id": str(source_id),
440                 "sink_node_id": str(sink_id),
441                 "source_channel": link.source_channel.name,
442                 "sink_channel": link.sink_channel.name,
443                 "enabled": "true" if link.enabled else "false",
444                 }
445        builder.start("link", attrs)
446        builder.end("link")
447
448    builder.end("links")
449
450    ## Annotations
451    annotation_ids = defaultdict(inf_range().next)
452    builder.start("annotations", {})
453    for annotation in scheme.annotations:
454        annot_id = annotation_ids[annotation]
455        attrs = {"id": str(annot_id)}
456        data = None
457        if isinstance(annotation, SchemeTextAnnotation):
458            tag = "text"
459            attrs.update({"rect": repr(annotation.rect)})
460
461            # Save the font attributes
462            font = annotation.font
463            attrs.update({"font-family": font.get("family", None),
464                          "font-size": font.get("size", None)})
465            attrs = [(key, value) for key, value in attrs.items() \
466                     if value is not None]
467            attrs = dict((key, unicode(value)) for key, value in attrs)
468
469            data = annotation.text
470
471        elif isinstance(annotation, SchemeArrowAnnotation):
472            tag = "arrow"
473            attrs.update({"start": repr(annotation.start_pos),
474                          "end": repr(annotation.end_pos)})
475
476            # Save the arrow color
477            try:
478                color = annotation.color
479                attrs.update({"fill": color})
480            except AttributeError:
481                pass
482
483            data = None
484        else:
485            log.warning("Can't save %r", annotation)
486            continue
487        builder.start(tag, attrs)
488        if data is not None:
489            builder.data(data)
490        builder.end(tag)
491
492    builder.end("annotations")
493
494    builder.start("thumbnail", {})
495    builder.end("thumbnail")
496
497    # Node properties/settings
498    builder.start("node_properties", {})
499    for node in scheme.nodes:
500        data = None
501        if node.properties:
502            try:
503                data, format = dumps(node.properties, format=data_format,
504                                     pickle_fallback=pickle_fallback)
505            except Exception:
506                log.error("Error serializing properties for node %r",
507                          node.title, exc_info=True)
508            if data is not None:
509                builder.start("properties",
510                              {"node_id": str(node_ids[node]),
511                               "format": format})
512                builder.data(data)
513                builder.end("properties")
514
515    builder.end("node_properties")
516    builder.end("scheme")
517    root = builder.close()
518    tree = ElementTree(root)
519    return tree
520
521
522def scheme_to_ows_stream(scheme, stream, pretty=False, pickle_fallback=False):
523    """
524    Write scheme to a a stream in Orange Scheme .ows (v 2.0) format.
525
526    Parameters
527    ----------
528    scheme : :class:`.Scheme`
529        A :class:`.Scheme` instance to serialize.
530    stream : file-like object
531        A file-like object opened for writing.
532    pretty : bool, optional
533        If `True` the output xml will be pretty printed (indented).
534    pickle_fallback : bool, optional
535        If `True` allow scheme node properties to be saves using pickle
536        protocol if properties cannot be saved using the default
537        notation.
538
539    """
540    tree = scheme_to_etree(scheme, data_format="literal",
541                           pickle_fallback=pickle_fallback)
542
543    if pretty:
544        indent(tree.getroot(), 0)
545
546    if sys.version_info < (2, 7):
547        # in Python 2.6 the write does not have xml_declaration parameter.
548        tree.write(stream, encoding="utf-8")
549    else:
550        tree.write(stream, encoding="utf-8", xml_declaration=True)
551
552
553def indent(element, level=0, indent="\t"):
554    """
555    Indent an instance of a :class:`Element`. Based on
556    (http://effbot.org/zone/element-lib.htm#prettyprint).
557
558    """
559    def empty(text):
560        return not text or not text.strip()
561
562    def indent_(element, level, last):
563        child_count = len(element)
564
565        if child_count:
566            if empty(element.text):
567                element.text = "\n" + indent * (level + 1)
568
569            if empty(element.tail):
570                element.tail = "\n" + indent * (level + (-1 if last else 0))
571
572            for i, child in enumerate(element):
573                indent_(child, level + 1, i == child_count - 1)
574
575        else:
576            if empty(element.tail):
577                element.tail = "\n" + indent * (level + (-1 if last else 0))
578
579    return indent_(element, level, True)
580
581
582def dumps(obj, format="literal", prettyprint=False, pickle_fallback=False):
583    """
584    Serialize `obj` using `format` ('json' or 'literal') and return its
585    string representation and the used serialization format ('literal',
586    'json' or 'pickle').
587
588    If `pickle_fallback` is True and the serialization with `format`
589    fails object's pickle representation will be returned
590
591    """
592    if format == "literal":
593        try:
594            return (literal_dumps(obj, prettyprint=prettyprint, indent=1),
595                    "literal")
596        except (ValueError, TypeError) as ex:
597            if not pickle_fallback:
598                raise
599
600            log.warning("Could not serialize to a literal string",
601                        exc_info=True)
602
603    elif format == "json":
604        try:
605            return (json.dumps(obj, indent=1 if prettyprint else None),
606                    "json")
607        except (ValueError, TypeError):
608            if not pickle_fallback:
609                raise
610
611            log.warning("Could not serialize to a json string",
612                        exc_info=True)
613
614    elif format == "pickle":
615        return pickle.dumps(obj), "pickle"
616
617    else:
618        raise ValueError("Unsupported format %r" % format)
619
620    if pickle_fallback:
621        log.warning("Using pickle fallback")
622        return pickle.dumps(obj), "pickle"
623    else:
624        raise Exception("Something strange happened.")
625
626
627def loads(string, format):
628    if format == "literal":
629        return literal_eval(string)
630    elif format == "json":
631        return json.loads(string)
632    elif format == "pickle":
633        return pickle.loads(string)
634    else:
635        raise ValueError("Unknown format")
636
637
638# This is a subset of PyON serialization.
639def literal_dumps(obj, prettyprint=False, indent=4):
640    """
641    Write obj into a string as a python literal.
642    """
643    memo = {}
644    NoneType = type(None)
645
646    def check(obj):
647        if type(obj) in [int, long, float, bool, NoneType, unicode, str]:
648            return True
649
650        if id(obj) in memo:
651            raise ValueError("{0} is a recursive structure".format(obj))
652
653        memo[id(obj)] = obj
654
655        if type(obj) in [list, tuple]:
656            return all(map(check, obj))
657        elif type(obj) is dict:
658            return all(map(check, chain(obj.iterkeys(), obj.itervalues())))
659        else:
660            raise TypeError("{0} can not be serialized as a python "
661                             "literal".format(type(obj)))
662
663    check(obj)
664
665    if prettyprint:
666        return pprint.pformat(obj, indent=indent)
667    else:
668        return repr(obj)
669
670
671literal_loads = literal_eval
Note: See TracBrowser for help on using the repository browser.