source: orange/Orange/utils/addons.py @ 10581:94a831b04ec3

Revision 10581:94a831b04ec3, 70.9 KB checked in by markotoplak, 2 years ago (diff)

Moved some other scripts from misc to utils and Orange imports and canvas not works, although not systematically tested.

Line 
1"""
2==============================
3Add-on Management (``addons``)
4==============================
5
6.. index:: add-ons
7
8Orange.utils.addons module provides a framework for Orange add-on management. As
9soon as it is imported, the following initialization takes place: the list of
10installed add-ons is loaded, their directories are added to python path
11(:obj:`sys.path`) the callback list is initialized the stored repository list is
12loaded. The most important consequence of importing the module is thus the
13ability to import add-ons' modules, because they are now in the python path.
14
15.. attribute:: available_repositories
16
17   List of add-on repository descriptors (instances of
18   :class:`OrangeAddOnRepository`).
19
20.. attribute:: addon_directories
21
22   List of directories that have been added to the path to make use of add-ons
23   possible; see :obj:`add_addon_directories_to_path`.
24
25.. attribute:: registered_addons
26
27   A list of registered add-on descriptors (instances of
28   :class:`OrangeRegisteredAddOn`).
29
30.. attribute:: available_addons
31
32   A dictionary mapping URLs of repositories to instances of
33   :class:`OrangeAddOnRepository`.
34
35.. attribute:: installed_addons
36
37   A dictionary mapping GUIDs to instances of :class:`OrangeAddOnInstalled`.
38
39.. autofunction:: load_installed_addons_from_dir
40
41.. autofunction:: repository_list_filename
42
43.. autofunction:: load_repositories
44
45.. autofunction:: save_repositories
46
47.. autofunction:: update_default_repositories
48
49.. autofunction:: add_addon_directories_to_path
50
51.. autofunction:: install_addon
52
53.. autofunction:: install_addon_from_repo
54
55.. autofunction:: load_addons
56
57.. autofunction:: refresh_addons
58
59.. autofunction:: register_addon
60
61.. autofunction:: unregister_addon
62
63Add-on descriptors and packaging routines
64=========================================
65
66.. autofunction:: suggest_version
67
68.. autoclass:: OrangeRegisteredAddOn
69   :members:
70   :show-inheritance:
71
72.. autoclass:: OrangeAddOn
73   :members:
74   :show-inheritance:
75
76.. autoclass:: OrangeAddOnInRepo
77   :members:
78   :show-inheritance:
79
80.. autoclass:: OrangeAddOnInstalled
81   :members:
82   :show-inheritance:
83
84Add-on repository descriptors
85=============================
86
87.. autoclass:: OrangeAddOnRepository
88   :members:
89   :show-inheritance:
90   
91.. autoclass:: OrangeDefaultAddOnRepository
92   :members:
93   :show-inheritance:
94
95Exception classes
96=================
97
98.. autoclass:: RepositoryException
99   :members:
100   :show-inheritance:
101
102.. autoclass:: InstallationException
103   :members:
104   :show-inheritance:
105
106.. autoclass:: PackingException
107   :members:
108   :show-inheritance:
109
110"""
111
112
113import xml.dom.minidom
114import re
115import os
116import shutil
117import sys
118import glob
119import time
120import socket
121import urllib  # urllib because we need 'urlretrieve'
122import urllib2 # urllib2 because it reports HTTP Errors for 'urlopen'
123import bisect
124import platform
125
126import Orange.utils.environ
127import widgetparser
128from fileutil import *
129from fileutil import _zip_open
130from zipfile import ZipFile
131
132import warnings
133
134socket.setdefaulttimeout(120)  # In seconds.
135
136class PackingException(Exception):
137    """
138    An exception that occurs during add-on packaging. Behaves exactly as
139    :class:`Exception`.
140   
141    """
142    pass
143
144def suggest_version(current_version):
145    """
146    Automatically construct a version string of form "year.month.day[.number]".
147    If the passed "current version" is already in this format and contains
148    identical date, the last number is incremented if it exists; otherwise ".1"
149    is appended.
150   
151    :param current_version: version on which to base the new version; is used
152        only in case it is in the same format.
153    :type current_version: str
154   
155    """
156   
157    version = time.strftime("%Y.%m.%d")
158    try:
159        xmlver_int = map(int, current_version.split("."))
160    except:
161        xmlver_int = []
162    ver_int = map(int, version.split("."))
163    if xmlver_int[:3] == ver_int[:3]:
164        version += ".%d" % ((xmlver_int[3] if len(xmlver_int)>3 else 0) +1)
165    return version
166
167class OrangeRegisteredAddOn():
168    """
169    An add-on that is not linked to an on-line repository, but resides in an
170    independent directory and has been registered in Orange to be loaded when
171    Canvas is run. Helper methods are also implemented to enable packaging of
172    a registered add-on into an .oao package, including methods to generate
173    a skeleton of documentation files.
174   
175    .. attribute:: id
176   
177       ID of the add-on. IDs of registered add-ons are in form
178       "registered:<dir>", where <dir> is the directory of add-on's files.
179   
180    .. attribute:: name
181       
182       name of the add-on.
183       
184    .. attribute:: directory
185   
186       the directory where the add-on's files reside.
187   
188    .. attribute:: systemwide
189   
190       a flag indicating whether the add-on is registered system-wide, i.e.
191       for all OS users.
192   
193    """
194   
195    def __init__(self, name, directory, systemwide=False):
196        """
197        Constructor only sets the attributes.
198       
199        :param name: name of the add-on.
200        :type name: str
201       
202        :param directory: full path to the add-on's files.
203        :type directory: str
204       
205        :param systemwide: determines whether the add-on is installed
206            systemwide, ie. for all users.
207        :type systemwide: boolean
208        """
209        self.name = name
210        self.directory = directory
211        self.systemwide = systemwide
212       
213        # Imitate real add-ons behaviour
214        self.id = "registered:"+directory
215
216    # Imitate real add-ons behaviour
217    def has_single_widget(self):
218        """
219        Always return False: this feature is not implemented for registered
220        add-ons.
221        """
222        return False
223
224    def directory_documentation(self):
225        """
226        Return the documentation directory -- the "doc" directory under the
227        add-on's directory.
228        """
229        return os.path.join(self.directory, "doc")
230
231    def uninstall(self, refresh=True):
232        """
233        Uninstall, or rather unregister, the registered add-on. The files in
234        add-on's directory are not deleted or in any other way changed.
235       
236        :param refresh: determines whether add-on list change callback
237            functions are to be called after the unregistration process. This
238            should always be True, except when multiple operations are executed
239            in a batch.
240        :type refresh: boolean
241        """
242        try:
243            unregister_addon(self.name, self.directory, user_only=True)           
244            if refresh:
245                refresh_addons()
246            return True
247        except Exception, e:
248            raise InstallationException("Unable to unregister add-on: %s" %
249                                        (self.name, e))
250
251    def prepare(self, id=None, name=42, version="auto", description=None,
252                tags=None, author_organizations=None, author_creators=None,
253                author_contributors=None, preferred_directory=None,
254                homepage=None):
255        """
256        Prepare the add-on for packaging into an .oao ZIP file and add the
257        necessary files to the add-on directory (possibly overwriting some!).
258
259        :param id: ID of the add-on. Must be a valid GUID; None means it is
260            retained from existing addon.xml if it exists, otherwise a new GUID
261            is generated.
262        :type id: str
263       
264        :param name: name of the add-on; None retains existing value if it
265            exists and raises exception otherwise; the default value of 42
266            uses :obj:`self.name`.
267        :type name: str
268           
269        :param version: version of the add-on. None retains existing value if
270            it exists and does the same as "auto" otherwise; "auto" generates a
271            new version number from the current date in format 'yyyy.mm.dd'
272            (see :obj:`Orange.utils.addons.suggest_version`); if that is equal
273            to the current version, another integer component is appended.
274        :type version: str
275       
276        :param description: add-on's description. None retains existing value
277            if it exists and raises an exception otherwise.
278        :type description: str
279       
280        :param tags: tags; None retains existing value if it exists, else
281            defaults to [].
282        :type tags: list of str
283       
284        :param author_organizations: list of authoring organizations. None
285            retains existing value if it exists, else defaults to [].
286        :type author_organizations: list of str
287       
288        :param author_creators: list of names of authors. None
289            retains existing value if it exists, else defaults to [].
290        :type author_creators: list of str
291
292        :param author_contributors: list of additional organizations or people
293            that have contributed to the add-on development. None
294            retains existing value if it exists, else defaults to [].
295        :type author_contributors: list of str
296
297        :param preferred_directory: default directory name for installation.
298            None retains existing value, "" removes the tag from the XML.
299        :type preferred_directory: str
300           
301        :param homepage: the URL of add-on's website. None retains existing
302            value, "" removes the tag from the XML.
303        :type homepage: str
304        """
305        ##########################
306        # addon.xml maintenance. #
307        ##########################
308        addon_xml_path = os.path.join(self.directory, "addon.xml")
309        try:
310            xmldoc = xml.dom.minidom.parse(addon_xml_path)
311        except Exception, e:
312            warnings.warn("Could not load addon.xml because \"%s\"; a new one "+
313                          "will be created." % e, Warning, 0)
314            impl = xml.dom.minidom.getDOMImplementation()
315            xmldoc = impl.createDocument(None, "OrangeAddOn", None)
316        xmldoc_root = xmldoc.documentElement
317        # GUID
318        if not id and not xml_text_of("id", parent=xmldoc_root):
319            # GUID needs to be generated
320            import uuid
321            id = str(uuid.uuid1())
322        if id:
323            xml_set(xmldoc_root, "id", id)
324        # name
325        if name==42:
326            name = self.name
327        if name and name.strip():
328            xml_set(xmldoc_root, "name", name.strip())
329        elif not xml_text_of("name", parent=xmldoc_root):
330            raise PackingException("'name' is a mandatory value!")
331        name = xml_text_of("name", parent=xmldoc_root)
332        # version
333        xml_version = xml_text_of("version", parent=xmldoc_root)
334        if not xml_version and not version:
335            version = "auto"
336        if version == "auto":
337            version = suggest_version(xml_version)
338        if version:
339            xml_set(xmldoc_root, "version", version)
340        # description
341        meta = get_element_nonrecursive(xmldoc_root, "meta", create=True)
342        if description and description.strip():
343            xml_set(meta, "description", description.strip())
344        elif not xml_text_of("description", parent=meta):
345            raise PackingException("'description' is a mandatory value!")
346        # tags
347        def update_list(root, node_name, list):
348            listNode = get_element_nonrecursive(root, node_name)
349            while listNode:
350                root.removeChild(listNode)
351                listNode = get_element_nonrecursive(root, node_name)
352            for value in list:
353                root.appendChild(create_text_element(node_name, value))
354        if tags!=None:
355            tags_node = get_element_nonrecursive(meta, "tags", create=True)
356            update_list(tags_node, "tag", tags)
357        # authors
358        if author_organizations!=None or author_contributors!=None or \
359           author_creators!=None:
360            authorsNode = get_element_nonrecursive(meta, "authors", create=True)
361            if author_organizations!=None: update_list(authorsNode,
362                                                       "organization",
363                                                       author_organizations)
364            if author_creators!=None:      update_list(authorsNode,
365                                                       "creator",
366                                                       author_creators)
367            if author_contributors!=None:  update_list(authorsNode,
368                                                       "contributor",
369                                                       author_contributors)
370        #  preferred_directory
371        if preferred_directory != None:
372            xml_set(xmldoc_root, "preferred_directory", preferred_directory
373                    if preferred_directory else None)
374        #  homepage
375        if homepage != None:
376            xml_set(xmldoc_root, "homepage", homepage if homepage else None)
377           
378        import codecs
379        xmldoc.writexml(codecs.open(addon_xml_path, 'w', "utf-8"),
380                        encoding="UTF-8")
381        sys.stderr.write("Updated addon.xml written.\n")
382
383        ##########################
384        # style.css creation     #
385        ##########################
386        localcss = os.path.join(self.directory_documentation(), "style.css")
387        orangecss = os.path.join(Orange.utils.environ.doc_install_dir, "style.css")
388        if not os.path.isfile(localcss):
389            if os.path.isfile(orangecss):
390                import shutil
391                shutil.copy(orangecss, localcss)
392                sys.stderr.write("doc/style.css created.\n")
393            else:
394                raise PackingException("Could not find style.css in orange"+\
395                                       " documentation directory.")
396
397        ##########################
398        # index.html creation    #
399        ##########################
400        if not os.path.isdir(self.directory_documentation()):
401            os.mkdir(self.directory_documentation())
402        hasIndex = False
403        for fname in ["main", "index", "default"]:
404            for ext in ["html", "htm"]:
405                hasIndex = hasIndex or os.path.isfile(os.path.join(self.directory_documentation(),
406                                                                   fname+"."+ext))
407        if not hasIndex:
408            indexFile = open( os.path.join(self.directory_documentation(),
409                                           "index.html"), 'w')
410            indexFile.write('<html><head><link rel="stylesheet" '+\
411                            'href="style.css" type="text/css" /><title>%s'+\
412                            '</title></head><body><h1>Module Documentation'+\
413                            '</h1>%s</body></html>' % (name+" Orange Add-on "+ \
414                                                       "Documentation",
415                            "This is where technical add-on module "+\
416                            "documentation is. Well, at least it <i>should</i>"+\
417                            " be."))
418            indexFile.close()
419            sys.stderr.write("doc/index.html written.\n")
420           
421        ##########################
422        # iconlist.html creation #
423        ##########################
424        wdocdir = os.path.join(self.directory_documentation(), "widgets")
425        if not os.path.isdir(wdocdir): os.mkdir(wdocdir)
426        open(os.path.join(wdocdir, "index.html"), 'w').write(self.iconlist_html())
427        sys.stderr.write("Widget list (doc/widgets/index.html) written.\n")
428
429        ##########################
430        # copying the icons      #
431        ##########################
432        icondir = os.path.join(self.directory, "widgets", "icons")
433        icondocdir = os.path.join(wdocdir, "icons")
434        proticondir = os.path.join(self.directory, "widgets", "prototypes",
435                                   "icons")
436        proticondocdir = os.path.join(wdocdir, "prototypes", "icons")
437
438        import shutil
439        iconbg_file = os.path.join(Orange.utils.environ.icons_install_dir, "background_32.png")
440        iconun_file = os.path.join(Orange.utils.environ.icons_install_dir, "Unknown.png")
441        if not os.path.isdir(icondocdir): os.mkdir(icondocdir)
442        if os.path.isfile(iconbg_file): shutil.copy(iconbg_file, icondocdir)
443        if os.path.isfile(iconun_file): shutil.copy(iconun_file, icondocdir)
444       
445        if os.path.isdir(icondir):
446            import distutils.dir_util
447            distutils.dir_util.copy_tree(icondir, icondocdir)
448        if os.path.isdir(proticondir):
449            import distutils.dir_util
450            if not os.path.isdir(os.path.join(wdocdir, "prototypes")):
451                os.mkdir(os.path.join(wdocdir, "prototypes"))
452            if not os.path.isdir(proticondocdir): os.mkdir(proticondocdir)
453            distutils.dir_util.copy_tree(proticondir, proticondocdir)
454        sys.stderr.write("Widget icons copied to doc/widgets/.\n")
455
456
457    #####################################################
458    # What follows are ugly HTML generators.            #
459    #####################################################
460    def widget_doc_skeleton(self, widget, prototype=False):
461        """
462        Return an HTML skeleton for documentation of a widget.
463       
464        :param widget: widget metadata.
465        :type widget: :class:`widgetparser.WidgetMetaData`
466       
467        :param prototype: determines, whether this is a prototype widget. This
468            is important to generate appropriate relative paths to the icons and
469            CSS.
470        :type prototype: boolean
471        """
472        wfile = os.path.splitext(os.path.split(widget.filename)[1])[0][2:]
473        pathprefix = "../" if prototype else ""
474        iconcode = '\n<p><img class="screenshot" style="z-index:2; border: none; height: 32px; width: 32px; position: relative" src="%s" title="Widget: %s" width="32" height="32" /><img class="screenshot" style="margin-left:-32px; z-index:1; border: none; height: 32px; width: 32px; position: relative" src="%sicons/background_32.png" width="32" height="32" /></p>' % (widget.icon, widget.name, pathprefix)
475       
476        inputscode = """<DT>(None)</DT>"""
477        outputscode = """<DT>(None)</DT>"""
478        il, ol = eval(widget.inputList), eval(widget.outputList)
479        if il:
480            inputscode = "\n".join(["<dt>%s (%s)</dt>\n<dd>Describe here, what this input does.</dd>\n" % (p[0], p[1]) for p in il])
481        if ol:
482            outputscode = "\n".join(["<dt>%s (%s)</dt>\n<dd>Describe here, what this output does.</dd>\n" % (p[0], p[1]) for p in ol])
483        html = """<html>
484<head>
485<title>%s</title>
486<link rel=stylesheet href="%s../style.css" type="text/css" media=screen>
487</head>
488
489<body>
490
491<h1>%s</h1>
492%s
493<p>This widget does this and that..</p>
494
495<h2>Channels</h2>
496
497<h3>Inputs</h3>
498
499<dl class=attributes>
500%s
501</dl>
502
503<h3>Outputs</h3>
504<dl class=attributes>
505%s
506</dl>
507
508<h2>Description</h2>
509
510<!-- <img class="leftscreenshot" src="%s.png" align="left"> -->
511
512<p>This is a widget which ...</p>
513
514<p>If you press <span class="option">Reload</span>, something will happen. <span class="option">Commit</span> button does something else.</p>
515
516<h2>Examples</h2>
517
518<p>This widget is used in this and that way. It often gets data from
519the <a href="Another.htm">Another Widget</a>.</p>
520
521<!-- <img class="schema" src="%s-Example.png" alt="Schema with %s widget"> -->
522
523</body>
524</html>""" % (widget.name, pathprefix, widget.name, iconcode, inputscode,
525              outputscode, wfile, wfile, widget.name)
526        return html
527       
528   
529    def iconlist_html(self, create_skeleton_docs=True):
530        """
531        Prepare and return an HTML document, containing a table of widget icons.
532       
533        :param create_skeleton_docs: determines whether documentation skeleton for
534            widgets without documentation should be generated (ie. whether the
535            method :obj:`widget_doc_skeleton` should be called.
536        :type create_skeleton_docs: boolean
537        """
538        html = """
539<style>
540div#maininner {
541  padding-top: 25px;
542}
543
544div.catdiv h2 {
545  border-bottom: none;
546  padding-left: 20px;
547  padding-top: 5px;
548  font-size: 14px;
549  margin-bottom: 5px;
550  margin-top: 0px;
551  color: #fe6612;
552}
553
554div.catdiv {
555  margin-left: 10px;
556  margin-right: 10px;
557  margin-bottom: 20px;
558  background-color: #eeeeee;
559}
560
561div.catdiv table {
562  width: 98%;
563  margin: 10px;
564  padding-right: 20px;
565}
566
567div.catdiv table td {
568  background-color: white;
569/*  height: 18px;*/
570  margin: 25px;
571  vertical-align: center;
572  border-left: solid #eeeeee 10px;
573  border-bottom: solid #eeeeee 3px;
574  font-size: 13px;
575}
576
577div.catdiv table td.left {
578  width: 3%;
579  height: 28px;
580  padding: 0;
581  margin: 0;
582}
583
584div.catdiv table td.left-nodoc {
585  width: 3%;
586  color: #aaaaaa;
587  padding: 0;
588  margin: 0
589}
590
591
592div.catdiv table td.right {
593  padding-left: 5px;
594  border-left: none;
595  width: 22%;
596  font-size: 11px;
597}
598
599div.catdiv table td.right-nodoc {
600  width: 22%;
601  padding-left: 5px;
602  border-left: none;
603  color: #aaaaaa;
604  font-size: 11px;
605}
606
607div.catdiv table td.empty {
608  background-color: #eeeeee;
609}
610
611
612.rnd1 {
613 height: 1px;
614 border-left: solid 3px #ffffff;
615 border-right: solid 3px #ffffff;
616 margin: 0px;
617 padding: 0px;
618}
619
620.rnd2 {
621 height: 2px;
622 border-left: solid 1px #ffffff;
623 border-right: solid 1px #ffffff;
624 margin: 0px;
625 padding: 0px;
626}
627
628.rnd11 {
629 height: 1px;
630 border-left: solid 1px #eeeeee;
631 border-right: solid 1px #eeeeee;
632 margin: 0px;
633 padding: 0px;
634}
635
636.rnd1l {
637 height: 1px;
638 border-left: solid 1px white;
639 border-right: solid 1px #eeeeee;
640 margin: 0px;
641 padding: 0px;
642}
643
644div.catdiv table img {
645  border: none;
646  height: 28px;
647  width: 28px;
648  position: relative;
649}
650</style>
651
652<script>
653function setElColors(t, id, color) {
654  t.style.backgroundColor=document.getElementById('cid'+id).style.backgroundColor = color;
655}
656</script>
657
658<p style="font-size: 16px; font-weight: bold">Catalog of widgets</p>
659        """
660        wdir = os.path.join(self.directory, "widgets")
661        pdir = os.path.join(wdir, "prototypes")
662        widgets = {}
663        for (prototype, filename) in [(False, filename) for filename in
664                                      glob.iglob(os.path.join(wdir, "*.py"))] +\
665                                     [(True, filename) for filename in
666                                      glob.iglob(os.path.join(pdir, "*.py"))]:
667            if os.path.isdir(filename):
668                continue
669            try:
670                meta =widgetparser.WidgetMetaData(file(filename).read(),
671                                                   "Prototypes" if prototype else "Uncategorized",
672                                                   enforceDefaultCategory=prototype,
673                                                   filename=filename)
674            except:
675                continue # Probably not an Orange Widget module; skip this file.
676            if meta.category in widgets:
677                widgets[meta.category].append((prototype, meta))
678            else:
679                widgets[meta.category] = [(prototype, meta)]
680        category_list = [cat for cat in widgets.keys()
681                         if cat not in ["Prototypes", "Uncategorized"]]
682        category_list.sort()
683        for cat in ["Uncategorized"] + category_list + ["Prototypes"]:
684            if cat not in widgets:
685                continue
686            html += """    <div class="catdiv">
687    <div class="rnd1"></div>
688    <div class="rnd2"></div>
689
690    <h2>%s</h2>
691    <table><tr>
692""" % cat
693            for i, (p, w) in enumerate(widgets[cat]):
694                if (i>0) and (i%4 == 0):
695                    html += "</tr><tr>\n"
696                wreldir = os.path.relpath(os.path.split(w.filename)[0], wdir)\
697                          if "relpath" in os.path.__dict__ else\
698                          os.path.split(w.filename)[0].replace(wdir, "")
699                docfile = os.path.join(wreldir,
700                                       os.path.splitext(os.path.split(w.filename)[1][2:])[0] + ".htm")
701               
702                iconfile = os.path.join(wreldir, w.icon)
703                if not os.path.isfile(os.path.join(wdir, iconfile)):
704                    iconfile = "icons/Unknown.png"
705                if os.path.isfile(os.path.join(self.directory_documentation(),
706                                               "widgets", docfile)):
707                    html += """<td id="cid%d" class="left"
708      onmouseover="this.style.backgroundColor='#fff7df'"
709      onmouseout="this.style.backgroundColor=null"
710      onclick="this.style.backgroundColor=null; window.location='%s'">
711      <div class="rnd11"></div>
712      <img style="z-index:2" src="%s" title="Widget: Text File" width="28" height="28" /><img style="margin-left:-28px; z-index:1" src="icons/background_32.png" width="28" height="28" />
713      <div class="rnd11"></div>
714  </td>
715
716  <td class="right"
717    onmouseover="setElColors(this, %d, '#fff7df')"
718    onmouseout="setElColors(this, %d, null)"
719    onclick="setElColors(this, %d, null); window.location='%s'">
720      %s
721</td>
722""" % (i, docfile, iconfile, i, i, i, docfile, w.name)
723                else:
724                    skeleton_filename = os.path.join(self.directory_documentation(),
725                                                     "widgets",
726                                                     docfile+".skeleton")
727                    if not os.path.isdir(os.path.dirname(skeleton_filename)):
728                        os.mkdir(os.path.dirname(skeleton_filename))
729                    open(skeleton_filename, 'w').write(self.widget_doc_skeleton(w, prototype=p))
730                    html += """  <td id="cid%d" class="left-nodoc">
731      <div class="rnd11"></div>
732      <img style="z-index:2" src="%s" title="Widget: Text File" width="28" height="28" /><img style="margin-left:-28px; z-index:1" src="icons/background_32.png" width="28" height="28" />
733      <div class="rnd11"></div>
734  </td>
735  <td class="right-nodoc">
736      <div class="rnd1l"></div>
737      %s
738      <div class="rnd1l"></div>
739
740  </td>
741""" % (i, iconfile, w.name)
742            html += '</tr></table>\n<div class="rnd2"></div>\n<div class="rnd1"></div>\n</div>\n'
743        return html
744    ###########################################################################
745    # Here end the ugly HTML generators. Only beautiful code from now on! ;) #
746    ###########################################################################
747       
748
749class OrangeAddOn():
750    """
751    Stores data about an add-on for Orange.
752
753    .. attribute:: id
754   
755       ID of the add-on. IDs of registered add-ons are in form
756       "registered:<dir>", where <dir> is the directory of add-on's files.
757   
758    .. attribute:: name
759       
760       name of the add-on.
761       
762    .. attribute:: architecture
763   
764       add-on structure version; currently it must have a value of 1.
765   
766    .. attribute:: homepage
767   
768       URL of add-on's web site.
769       
770    .. attribute:: version_str
771       
772       string representation of add-on's version; must be a period-separated
773       list of integers.
774       
775    .. attribute:: version
776   
777       parsed value of the :obj:`version_str` attribute - a list of integers.
778   
779    .. attribute:: description
780   
781       textual description of the add-on.
782       
783    .. attribute:: tags
784   
785       textual tags that describe the add-on - a list of strings.
786   
787    .. attribute:: author_organizations
788   
789       a list of strings with names of organizations that developed the add-on.
790
791    .. attribute:: author_creators
792   
793       a list of strings with names of individuals (persons) that developed the
794       add-on.
795
796    .. attribute:: author_contributors
797   
798       a list of strings with names of organizations and individuals (persons)
799       that have made minor contributions to the add-on.
800   
801    .. attribute:: preferred_directory
802   
803       preferred name of the subdirectory under which the add-on is to be
804       installed. It is not guaranteed this directory name will be used; for
805       example, when such a directory already exists, another name will be
806       generated during installation.
807    """
808
809    def __init__(self, xmlfile=None):
810        """
811        Initialize an empty add-on descriptor. Initializes attributes with data
812        from an optionally passed XML add-on descriptor; otherwise sets all
813        attributes to None or, in case of list attributes, an empty list.
814       
815        :param xmlfile: an optional file name or an instance of minidom's
816            Element with XML add-on descriptor.
817        :type xmlfile: :class:`xml.dom.minidom.Element` or str or
818            :class:`NoneType`
819        """
820        self.name = None
821        self.architecture = None
822        self.homepage = None
823        self.id = None
824        self.version_str = None
825        self.version = None
826       
827        self.description = None
828        self.tags = []
829        self.author_organizations = []
830        self.author_creators = []
831        self.author_contributors = []
832       
833        self.preferred_directory = None
834       
835        self.widgets = []  # List of widgetparser.WidgetMetaData objects
836       
837        if xmlfile:
838            xml_doc_root = xmlfile if xmlfile.__class__ is xml.dom.minidom.Element else\
839                         xml.dom.minidom.parse(xmlfile).documentElement
840            try:
841                self.parsexml(xml_doc_root)
842            finally:
843                xml_doc_root.unlink()
844
845    def clone(self, new=None):
846        """
847        Clone the add-on descriptor, effectively making a deep copy.
848       
849        :param new: a new instance of this class into which to copy the values
850            of attributes; if None, a new instance is constructed.
851        :type new: :class:`OrangeAddOn` or :class:`NoneType`
852        """
853        if not new:
854            new = OrangeAddOn()
855        new.name = self.name
856        new.architecture = self.architecture
857        new.homepage = self.homepage
858        new.id = self.id
859        new.version_str = self.version_str
860        new.version = list(self.version)
861        new.description = self.description
862        new.tags = list(self.tags)
863        new.author_organizations = list(self.author_organizations)
864        new.author_creator = list(self.author_creators)
865        new.author_contributors = list(self.author_contributors)
866        new.prefferedDirectory = self.preferred_directory
867        new.widgets = [w.clone() for w in self.widgets]
868        return new
869
870    def directory_documentation(self):
871        """
872        Return the documentation directory -- the "doc" directory under the
873        add-on's directory.
874        """
875        #TODO This might be redefined in orngConfiguration.
876        return os.path.join(self.directory, "doc")
877
878    def parsexml(self, root):
879        """
880        Parse the add-on's XML descriptor and set object's attributes
881        accordingly.
882       
883        :param root: root of the add-on's descriptor (the node with tag name
884            "OrangeAddOn").
885        :type root: :class:`xml.dom.minidom.Element`
886        """
887        if root.tagName != "OrangeAddOn":
888            raise Exception("Invalid XML add-on descriptor: wrong root element name!")
889       
890        mandatory = ["id", "architecture", "name", "version", "meta"]
891        textnodes = {"id": "id", "architecture": "architecture", "name": "name",
892                     "version": "version_str", 
893                     "preferredDirectory": "preferredDirectory",
894                     "homePage": "homepage"}
895        for node in [n for n in root.childNodes if n.nodeType==n.ELEMENT_NODE]:
896            if node.tagName in mandatory:
897                mandatory.remove(node.tagName)
898               
899            if node.tagName in textnodes:
900                setattr(self, textnodes[node.tagName],
901                        widgetparser.xml_text_of(node))
902            elif node.tagName == "meta":
903                for node in [n for n in node.childNodes
904                             if n.nodeType==n.ELEMENT_NODE]:
905                    if node.tagName == "description":
906                        self.description = widgetparser.xml_text_of(node, True)
907                    elif node.tagName == "tags":
908                        for tagNode in [n for n in node.childNodes
909                                        if n.nodeType==n.ELEMENT_NODE and
910                                        n.tagName == "tag"]:
911                            self.tags.append(widgetparser.xml_text_of(tagNode))
912                    elif node.tagName == "authors":
913                        authorTypes = {"organization": self.author_organizations,
914                                       "creator": self.author_creators,
915                                       "contributor": self.author_contributors}
916                        for authorNode in [n for n in node.childNodes
917                                           if n.nodeType==n.ELEMENT_NODE and
918                                           n.tagName in authorTypes]:
919                            authorTypes[authorNode.tagName].append(widgetparser.xml_text_of(authorNode))
920            elif node.tagName == "widgets":
921                for node in [n for n in node.childNodes
922                             if n.nodeType==n.ELEMENT_NODE]:
923                    if node.tagName == "widget":
924                        self.widgets.append(widgetparser.WidgetMetaData(node))
925       
926        if "afterparse" in self.__class__.__dict__:
927            self.afterparse(root)
928       
929        self.validate_architecture()
930        if mandatory:
931            raise Exception("Mandatory elements missing: "+", ".join(mandatory))
932        self.validate_id()
933        self.validate_name()
934        self.validate_version()
935        self.validate_description()
936        if self.preferred_directory==None:
937            self.preferred_directory = self.name
938
939    def validate_architecture(self):
940        """
941        Raise an exception if the :obj:`architecture` (structure of the add-on)
942        is not supported. Currently, only architecture 1 exists.
943        """
944        if self.architecture != "1":
945            raise Exception("Only architecture '1' is supported by current Orange!")
946   
947    def validate_id(self):
948        """
949        Raise an exception if the :obj:`id` is not a valid GUID.
950        """
951        idPattern = re.compile("[0-9a-z]{8}-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{12}")
952        if not idPattern.match(self.id):
953            raise Exception("Invalid ID!")
954
955    def validate_name(self):
956        """
957        Raise an exception if the :obj:`name` is empty (or contains only
958        whitespace).
959        """
960        if self.name.strip() == "":
961            raise Exception("Name is a mandatory field!")
962   
963    def validate_version(self):
964        """
965        Parse the :obj:`version_str` and populate the :obj:`version` attribute.
966        Raise an exception if the version is not in correct format (ie. a
967        period-separated list of integers).
968        """
969        self.version = [] 
970        for sub in self.version_str.split("."):
971            try:
972                self.version.append(int(sub))
973            except:
974                self.version = []
975                raise Exception("Invalid version string: '%s' is not an integer!" % sub)
976        self.version_str = ".".join(map(str,self.version))
977           
978    def validate_description(self):
979        """
980        Raise an exception if the :obj:`description` is empty (or contains only
981        whitespace).
982        """
983        if self.name.strip() == "":
984            raise Exception("Description is a mandatory field!")
985       
986    def has_single_widget(self):
987        """
988        Determine whether the add-on contains less than two widgets.
989        """
990        return len(self.widgets) < 2
991       
992
993class OrangeAddOnInRepo(OrangeAddOn):
994    """
995    Stores data about an add-on for Orange that exists in a repository.
996    Additional attributes are:
997   
998    .. attribute:: repository
999   
1000    A repository object (instance of :class:`OrangeAddOnRepository`) that
1001    contains data about the add-on's repository.
1002
1003    .. attribute:: filename
1004   
1005    The name of .oao file in repository.
1006   
1007    """
1008     
1009    def __init__(self, repository, filename=None, xmlfile=None):
1010        """
1011        Constructor only sets the attributes.
1012       
1013        :param repository: the repository that contains the add-on.
1014        :type repostitory: :class:`OrangeAddOnRepository`
1015       
1016        :param filename: name of the .oao file in repository (is used only if
1017            the XML file does not specify the filename).
1018        :type filename: str
1019       
1020        :param xmlfile: an optional file name or an instance of minidom's
1021            Element with XML add-on descriptor.
1022        :type xmlfile: :class:`xml.dom.minidom.Element` or str or
1023            :class:`NoneType`
1024        """
1025        OrangeAddOn.__init__(self, xmlfile)
1026        self.repository = repository
1027        if "filename" not in self.__dict__:
1028            self.filename = filename
1029   
1030    def afterparse(self, xml_root):  # Called by OrangeAddOn.parsexml()
1031        """
1032        Read the filename attribute from the XML. This method is called by
1033        :obj:`OrangeAddOn.parsexml`.
1034        """
1035        if xml_root.hasAttribute("filename"):
1036            self.filename = xml_root.getAttribute("filename")
1037           
1038    def clone(self, new=None):
1039        """
1040        Clone the add-on descriptor, effectively making a deep copy.
1041       
1042        :param new: a new instance of this class into which to copy the values
1043            of attributes; if None, a new instance is constructed.
1044        :type new: :class:`OrangeAddOn` or :class:`NoneType`
1045        """
1046        if not new:
1047            new = OrangeAddOnInRepo(self.repository)
1048        new.filename = self.filename
1049        return OrangeAddOn.clone(self, new)
1050
1051class OrangeAddOnInstalled(OrangeAddOn):
1052    """
1053    Stores data about an add-on for Orange that has been installed from a
1054    repository. Additional attribute is:
1055   
1056    .. attribute:: directory
1057   
1058    Directory of add-on's files.
1059   
1060    """
1061    def __init__(self, directory):
1062        """
1063        Constructor only sets the attributes.
1064       
1065        :param directory: directory of add-on's files, including an XML
1066            descriptor to read.
1067        :type directory: str
1068        """
1069        OrangeAddOn.__init__(self, os.path.join(directory, "addon.xml")
1070                             if directory else None)
1071        self.directory = directory
1072   
1073    def uninstall(self, refresh=True):
1074        """
1075        Uninstall the installed add-on. WARNING: all files in add-on's directory
1076        are deleted!
1077       
1078        :param refresh:  determines whether add-on list change callback
1079            functions are to be called after the unregistration process. This
1080            should always be True, except when multiple operations are executed
1081            in a batch.
1082        :type refresh: boolean
1083        """
1084        try:
1085            _deltree(self.directory)
1086            del installed_addons[self.id]
1087            if refresh:
1088                refresh_addons()
1089            return True
1090        except Exception, e:
1091            raise InstallationException("Unable to remove add-on: %s" %
1092                                        (self.name, e))
1093       
1094    def clone(self, new=None):
1095        """
1096        Clone the add-on descriptor, effectively making a deep copy.
1097       
1098        :param new: a new instance of this class into which to copy the values
1099            of attributes; if None, a new instance is constructed.
1100        :type new: :class:`OrangeAddOn` or :class:`NoneType`
1101        """
1102        if not new:
1103            new = OrangeAddOnInstalled(None)
1104        new.directory = self.directory
1105        return OrangeAddOn.clone(self, new)
1106       
1107available_addons = {}  # RepositoryURL -> OrangeAddOnRepository object
1108installed_addons = {}  # ID -> OrangeAddOnInstalled object
1109registered_addons = [] # OrangeRegisteredAddOn objects
1110
1111class RepositoryException(Exception):
1112    """
1113    An exception that occurs during access to repository location. Behaves
1114    exactly as :class:`Exception`.
1115
1116    """
1117    pass
1118
1119global index_re
1120index_re = "[^a-z0-9-']"  # RE for splitting entries in the search index
1121
1122class OrangeAddOnRepository:
1123    """
1124    Repository of Orange add-ons.
1125   
1126    .. attribute:: name
1127   
1128    A local descriptive name for the repository.
1129   
1130    .. attribute:: url
1131   
1132    URL of the repository root; http and file protocols are supported.
1133   
1134    .. attribute:: addons
1135   
1136    A dictionary mapping GUIDs to lists of add-on objects (of class
1137    :class:`OrangeAddOnInRepo`). Each GUID is thus mapped to at least one,
1138    but possibly more, different versions of add-on.
1139   
1140    .. attribute:: index
1141   
1142    A search index: sorted list of tuples (s, GUID), where such an entry
1143    signifies that when searching for a string that s starts with, add-on with
1144    the given GUID should be among results.
1145   
1146    .. attribute:: last_refresh_utc
1147   
1148    :obj:`time.time` of the last reloading of add-on list.
1149   
1150    .. attribute:: has_web_script
1151   
1152    A boolean indicating whether this is an http repository that contains the
1153    appropriate server-side python script that returns an XML with a list of
1154    add-ons.
1155   
1156    """
1157   
1158    def __init__(self, name, url, load=True, force=False):
1159        """
1160        :param name: a local descriptive name for the repository.
1161        :type name: str
1162       
1163        :param url: URL of the repository root; http and file protocols are
1164            supported. If the protocol is not given, file:// is assumed.
1165        :type url: str
1166       
1167        :param load: determines whether the list of repository's add-ons should
1168            be loaded immediately.
1169        :type load: boolean
1170       
1171        :param force: determines whether loading of repository's add-on list
1172            is mandatory, ie. if an exception is to be raised in case of
1173            connection failure.
1174        :type force: boolean
1175        """
1176       
1177        self.name = name
1178        self.url = url
1179        self.checkurl()
1180        self.addons = {}
1181        self.index = []
1182        self.last_refresh_utc = 0
1183        self._refresh_index()
1184        self.has_web_script = False
1185        if load:
1186            try:
1187                self.refreshdata(True, True)
1188            except Exception, e:
1189                if force:
1190                    warnings.warn("Couldn't load data from repository '%s': %s"
1191                                  % (self.name, e), Warning, 0)
1192                    return
1193                raise e
1194       
1195    def clone(self, new=None):
1196        """
1197        Clone the repository descriptor, effectively making a deep copy.
1198       
1199        :param new: a new instance of this class into which to copy the values
1200            of attributes; if None, a new instance is constructed.
1201        :type new: :class:`OrangeAddOnRepository` or :class:`NoneType`
1202        """
1203        if not new:
1204            new = OrangeAddOnRepository(self.name, self.url, load=False)
1205        new.addons = {}
1206        for (id, versions) in self.addons.items():
1207            new.addons[id] = [ao.clone() for ao in versions]
1208        new.index = list(self.index)
1209        new.last_refresh_utc = self.last_refresh_utc
1210        new.has_web_script = self.has_web_script if hasattr(self, 'has_web_script') else False
1211        return new
1212
1213    def checkurl(self):
1214        """
1215        Check the URL for validity. Return True if it begins with "file://" or
1216        "http://" or if it does not specify a protocol (in this case, file:// is
1217        assumed).
1218        """
1219        supportedProtocols = ["file", "http"]
1220        if "://" not in self.url:
1221            self.url = "file://"+self.url
1222        protocol = self.url.split("://")[0]
1223        if protocol not in supportedProtocols:
1224            raise Exception("Unable to load repository data: protocol '%s' not supported!" %
1225                            protocol)
1226
1227    def _add_addon(self, addon):
1228        """
1229        Add the given addon descriptor to the :obj:`addons` dictionary.
1230        Operation is sucessful only if there is no add-on with equal GUID
1231        (:obj:`OrangeAddOn.id`) and version
1232        (:obj:`OrangeAddOn.version`) already in this repository.
1233       
1234        :param addon: add-on descriptor to add.
1235        :type addon: :class:`OrangeAddOnInRepo`
1236        """
1237        if addon.id in self.addons:
1238            versions = self.addons[addon.id]
1239            for version in versions:
1240                if version.version == addon.version:
1241                    warnings.warn("Ignoring the second occurence of addon '%s'"+
1242                                  ", version '%s'." % (addon.name,
1243                                                       addon.version_str),
1244                                  Warning, 0)
1245                    return
1246            versions.append(addon)
1247        else:
1248            self.addons[addon.id] = [addon]
1249
1250    def _add_packed_addon(self, oaofile, filename=None):
1251        """
1252        Given a local path to an .oao file, add the addon descriptor to the
1253        :obj:`addons` dictionary. Specifically, "addon.xml" manifest is unpacked
1254        from the .oao, an :class:`OrangeAddOnInRepo` instance is constructed
1255        and :obj:`_add_addon` is invoked.
1256       
1257        :param oaofile: path to the .oao file.
1258        :type oaofile: str
1259       
1260        :param filename: name of the .oao file within the repository.
1261        :type filename: str
1262        """
1263        pack = ZipFile(oaofile, 'r')
1264        try:
1265            manifestfile = _zip_open(pack, 'addon.xml')
1266            manifest = xml.dom.minidom.parse(manifestfile).documentElement
1267            manifest.appendChild(widgetparser.widgets_xml(pack))
1268            addon = OrangeAddOnInRepo(self, filename, xmlfile=manifest)
1269            self._add_addon(addon)
1270        except Exception, e:
1271            raise Exception("Unable to load add-on descriptor: %s" % e)
1272   
1273    def refreshdata(self, force=False, firstload=False, interval=3600*24):
1274        """
1275        Refresh the add-on list if necessary. For an http repository, the
1276        server-side python script is invoked. If that fails, or if the
1277        repository is on local filesystem (file://), all .oao files are
1278        downloaded, unpacked and their manifests (addon.xml) are parsed.
1279       
1280        :param force: force a refresh, even if less than a preset amount of
1281            time (see parameter :obj:`interval`) has passed since last refresh
1282            (see attribute :obj:`last_refresh_utc`).
1283        :type force: boolean
1284       
1285        :param firstload: determines, whether this is the first loading of
1286            repository's contents. Right now, the only difference is that when
1287            there is no server-side repository script on an http repository and
1288            there are also no .oao files, this results in an exception if
1289            this parameter is set to True, and in a warning otherwise.
1290        :type firstload: boolean
1291       
1292        :parameter interval: an amount of time in seconds that must pass since
1293            last refresh (:obj:`last_refresh_utc`) to make the refresh happen.
1294        :type interval: int
1295        """
1296        if force or (self.last_refresh_utc < time.time() - interval):
1297            self.last_refresh_utc = time.time()
1298            self.has_web_script = False
1299            try:
1300                protocol = self.url.split("://")[0]
1301                if protocol == "http": # A remote repository
1302                    # Try to invoke a server-side script to retrieve add-on index (and therefore avoid downloading archives)
1303                    repositoryXmlDoc = None
1304                    try:
1305                        repositoryXmlDoc = urllib2.urlopen(self.url+"/addOnServer.py?machine=1")
1306                        repositoryXml = xml.dom.minidom.parse(repositoryXmlDoc).documentElement
1307                        if repositoryXml.tagName != "OrangeAddOnRepository":
1308                            raise Exception("Invalid XML add-on repository descriptor: wrong root element name!")
1309                        self.addons = {}
1310                        for (i, node) in enumerate([n for n
1311                                                    in repositoryXml.childNodes
1312                                                    if n.nodeType==n.ELEMENT_NODE]):
1313                            if node.tagName == "OrangeAddOn":
1314                                try:
1315                                    addon = OrangeAddOnInRepo(self, xmlfile=node)
1316                                    self._add_addon(addon)
1317                                except Exception, e:
1318                                    warnings.warn("Ignoring node nr. %d in "+
1319                                                  "repository '%s' because of"+
1320                                                  " an error: %s" % (i+1,
1321                                                                     self.name,
1322                                                                     e),
1323                                                  Warning, 0)
1324                        self.has_web_script = True
1325                        return True
1326                    except Exception, e:
1327                        warnings.warn("A problem occurred using server-side script on repository '%s': %s.\nAll add-ons need to be downloaded for their metadata to be extracted!"
1328                                      % (self.name, str(e)), Warning, 0)
1329
1330                    # Invoking script failed - trying to get and parse a directory listing
1331                    try:
1332                        repoconn = urllib2.urlopen(self.url+'abc')
1333                        response = "".join(repoconn.readlines())
1334                    except Exception, e:
1335                        raise RepositoryException("Unable to load repository data: %s" % e)
1336                    addOnFiles = map(lambda x: x.split('"')[1],
1337                                     re.findall(r'href\s*=\s*"[^"/?]*\.oao"',
1338                                                response))
1339                    if len(addOnFiles)==0:
1340                        if firstload:
1341                            raise RepositoryException("Unable to load reposito"+
1342                                                      "ry data: this is not an"+
1343                                                      " Orange add-on "+
1344                                                      "repository!")
1345                        else:
1346                            warnings.warn("Repository '%s' is empty ..." %
1347                                          self.name, Warning, 0)
1348                    self.addons = {}
1349                    for addOnFile in addOnFiles:
1350                        try:
1351                            addOnTmpFile = urllib.urlretrieve(self.url+"/"+addOnFile)[0]
1352                            self._add_packed_addon(addOnTmpFile, addOnFile)
1353                        except Exception, e:
1354                            warnings.warn("Ignoring '%s' in repository '%s' "+
1355                                          "because of an error: %s" %
1356                                          (addOnFile, self.name, e),
1357                                          Warning, 0)
1358                elif protocol == "file": # A local repository: open each and every archive to obtain data
1359                    dir = self.url.replace("file://","")
1360                    if not os.path.isdir(dir):
1361                        raise RepositoryException("Repository '%s' is not valid: '%s' is not a directory." % (self.name, dir))
1362                    self.addons = {}
1363                    for addOnFile in glob.glob(os.path.join(dir, "*.oao")):
1364                        try:
1365                            self._add_packed_addon(addOnFile,
1366                                                  os.path.split(addOnFile)[1])
1367                        except Exception, e:
1368                            warnings.warn("Ignoring '%s' in repository '%s' "+
1369                                          "because of an error: %s" %
1370                                          (addOnFile, self.name, e),
1371                                          Warning, 0)
1372                return True
1373            finally:
1374                self._refresh_index()
1375        return False
1376       
1377    def _add_to_index(self, addon, text):
1378        """
1379        Add the words, found in given text, to the search index, to be
1380        associated with given add-on.
1381       
1382        :param addon: add-on to add to the search index.
1383        :type addon: :class:`OrangeAddOnInRepo`
1384       
1385        :param text: text from which to extract words to be added to the index.
1386        :type text: str
1387        """
1388        words = [word for word in re.split(index_re, text.lower())
1389                 if len(word)>1]
1390        for word in words:
1391            bisect.insort_right(self.index, (word, addon.id) )
1392               
1393    def _refresh_index(self):
1394        """
1395        Rebuild the search index.
1396        """
1397        self.index = []
1398        for addOnVersions in self.addons.values():
1399            for addOn in addOnVersions:
1400                for str in [addOn.name, addOn.description] + addOn.author_creators + addOn.author_contributors + addOn.author_organizations + addOn.tags +\
1401                           [" ".join([w.name, w.contact, w.description, w.category, w.tags]) for w in addOn.widgets]:
1402                    self._add_to_index(addOn, str)
1403        self.last_search_phrase = None
1404        self.last_search_result = None
1405                   
1406    def search_index(self, phrase):
1407        """
1408        Search the word index for the given phrase and return a list of
1409        matching add-ons' GUIDs. The given phrase is split into sequences
1410        of alphanumeric characters, just like strings are split when
1411        building the index, and resulting add-ons match all of the words in
1412        the phrase.
1413       
1414        :param phrase: a phrase to search.
1415        :type phrase: str
1416        """
1417        if phrase == self.last_search_phrase:
1418            return self.last_search_result
1419       
1420        words = [word for word in re.split(index_re, phrase.lower()) if word!=""]
1421        result = set(self.addons.keys())
1422        for word in words:
1423            subset = set()
1424            i = bisect.bisect_left(self.index, (word, ""))
1425            while self.index[i][0][:len(word)] == word:
1426                subset.add(self.index[i][1])
1427                i += 1
1428                if i>= len(self.index): break
1429            result = result.intersection(subset)
1430        self.last_search_phrase = phrase
1431        self.last_search_result = result
1432        return result
1433       
1434class OrangeDefaultAddOnRepository(OrangeAddOnRepository):
1435    """
1436    Repository of Orange add-ons that is added by default.
1437   
1438    It has a hard-coded name of "Default Orange Repository (orange.biolab.si)"
1439    and URL "http://orange.biolab.si/add-ons/"; those arguments cannot be
1440    passed to the constructor. Also, the :obj:`force` parameter is set to
1441    :obj:`True`. Other parameters are passed to the superclass' constructor.
1442    """
1443   
1444    def __init__(self, **args):
1445        OrangeAddOnRepository.__init__(self, "Default Orange Repository (orange.biolab.si)",
1446                                       "http://orange.biolab.si/add-ons/",
1447                                       force=True, **args)
1448       
1449    def clone(self, new=None):
1450        if not new:
1451            new = OrangeDefaultAddOnRepository(load=False)
1452        new.name = self.name
1453        new.url = self.url
1454        return OrangeAddOnRepository.clone(self, new)
1455       
1456def load_installed_addons_from_dir(dir):
1457    """
1458    Populate the :obj:`installed_addons` dictionary with add-ons, installed
1459    into direct subdirectories of the given directory.
1460   
1461    :param dir: directory to search for add-ons.
1462    :type dir: str
1463    """
1464    if os.path.isdir(dir):
1465        for name in os.listdir(dir):
1466            addOnDir = os.path.join(dir, name)
1467            if not os.path.isdir(addOnDir) or name.startswith("."):
1468                continue
1469            try:
1470                addOn = OrangeAddOnInstalled(addOnDir)
1471            except Exception, e:
1472                warnings.warn("Add-on in directory '%s' has no valid descriptor (addon.xml): %s" % (addOnDir, e), Warning, 0)
1473                continue
1474            if addOn.id in installed_addons:
1475                warnings.warn("Add-on in directory '%s' has the same ID as the addon in '%s'!" % (addOnDir, installed_addons[addOn.id].directory), Warning, 0)
1476                continue
1477            installed_addons[addOn.id] = addOn
1478
1479def repository_list_filename():
1480    """
1481    Return the full filename of pickled add-on repository list. It resides
1482    within Orange settings directory.
1483    """
1484    orange_settings_dir = os.path.realpath(Orange.utils.environ.orange_settings_dir)
1485    list_file_name = os.path.join(orange_settings_dir, "repositoryList.pickle")
1486    if not os.path.isfile(list_file_name):
1487        # Try to move the config from the old location.
1488        try:
1489            canvas_settings_dir = os.path.realpath(Orange.utils.environ.canvas_settings_dir)
1490            old_list_file_name = os.path.join(canvas_settings_dir, "repositoryList.pickle")
1491            shutil.move(old_list_file_name, list_file_name)
1492        except:
1493            pass
1494   
1495    return list_file_name
1496
1497available_repositories = None
1498           
1499def load_repositories(refresh=True):
1500    """
1501    Populate the :obj:`available_repositories` list by reading the pickled
1502    repository list and adding the default repository
1503    (http://orange.biolab.si/addons) if it is not yet on the list. Optionally,
1504    lists of add-ons in repositories are refreshed.
1505   
1506    :param refresh: determines whether the add-on lists of repositories should
1507        be refreshed.
1508    :type refresh: boolean
1509    """
1510    listFileName = repository_list_filename()
1511    global available_repositories
1512    available_repositories = []
1513    if os.path.isfile(listFileName):
1514        try:
1515            import cPickle
1516            file = open(listFileName, 'rb')
1517            available_repositories = [repo.clone() for repo
1518                                      in cPickle.load(file)]
1519            file.close()
1520        except Exception, e:
1521            warnings.warn("Unable to load repository list! Error: %s" % e, Warning, 0)
1522    try:
1523        update_default_repositories(refresh=refresh)
1524    except Exception, e:
1525        warnings.warn("Unable to refresh default repositories: %s" % (e), Warning, 0)
1526
1527    if refresh:
1528        for r in available_repositories:
1529            #TODO: # Should show some progress (and enable cancellation)
1530            try:
1531                r.refreshdata(force=False)
1532            except Exception, e:
1533                warnings.warn("Unable to refresh repository %s! Error: %s" % (r.name, e), Warning, 0)
1534    save_repositories()
1535
1536def save_repositories():
1537    """
1538    Save the add-on repository list (:obj:`available_repositories`) to a
1539    specific file (see :obj:`repository_list_filename`).
1540    """
1541    listFileName = repository_list_filename()
1542    try:
1543        import cPickle
1544        global available_repositories
1545        cPickle.dump(available_repositories, open(listFileName, 'wb'))
1546    except Exception, e:
1547        warnings.warn("Unable to save repository list! Error: %s" % e, Warning, 0)
1548   
1549
1550def update_default_repositories(refresh=True):
1551    """
1552    Make sure the appropriate default repository (and no other
1553    :class:`OrangeDefaultAddOnRepository`) is in :obj:`available_repositories`.
1554    This function is called by :obj:`load_repositories`.
1555   
1556    :param refresh: determines whether the add-on list of added default
1557        repository should be refreshed.
1558    :type refresh: boolean
1559    """
1560    global available_repositories
1561    default = [OrangeDefaultAddOnRepository(load=False)]
1562    defaultKeys = [(repo.url, repo.name) for repo in default]
1563    existingKeys = [(repo.url, repo.name) for repo in available_repositories]
1564   
1565    for i, key in enumerate(defaultKeys):
1566        if key not in existingKeys:
1567            available_repositories.append(default[i])
1568            if refresh:
1569                default[i].refreshdata(firstload=True)
1570   
1571    to_remove = []
1572    for i, key in enumerate(existingKeys):
1573        if isinstance(available_repositories[i], OrangeDefaultAddOnRepository) and \
1574           key not in defaultKeys:
1575            to_remove.append(available_repositories[i])
1576    for tr in to_remove:
1577        available_repositories.remove(tr)
1578
1579addon_directories = []
1580def add_addon_directories_to_path():
1581    """
1582    Add directories, related to installed add-ons, to python path, if they are
1583    not yet there. Added directories are also stored into
1584    :obj:`addon_directories`. If this function is called more than once, the
1585    non-first invocation first removes the entries in :obj:`addon_directories`
1586    from the path.
1587   
1588    If an add-on is installed in directory D, those directories are added to
1589    python path (:obj:`sys.path`):
1590   
1591      - D,
1592      - D/widgets
1593      - D/widgets/prototypes
1594      - D/lib-<platform>
1595     
1596   Here, <platform> is a "-"-separated concatenation of :obj:`sys.platform`,
1597   result of :obj:`platform.machine` (an empty string is replaced by "x86") and
1598   comma-separated first two components of :obj:`sys.version_info`.
1599   """
1600    import os, sys
1601    global addon_directories, registered_addons
1602    sys.path = [dir for dir in sys.path if dir not in addon_directories]
1603    for addOn in installed_addons.values() + registered_addons:
1604        path = addOn.directory
1605        for p in [os.path.join(path, "widgets", "prototypes"),
1606                  os.path.join(path, "widgets"),
1607                  path,
1608                  os.path.join(path, "lib-%s" % "-".join(( sys.platform, "x86"
1609                                                           if (platform.machine()=="")
1610                                                           else platform.machine(),
1611                                                           ".".join(map(str, sys.version_info[:2])) )) )]:
1612            if os.path.isdir(p) and not any([Orange.utils.environ.samepath(p, x)
1613                                             for x in sys.path]):
1614                if p not in sys.path:
1615                    addon_directories.append(p)
1616                    sys.path.insert(0, p)
1617
1618def _deltree(dirname):
1619     if os.path.exists(dirname):
1620        for root,dirs,files in os.walk(dirname):
1621                for dir in dirs:
1622                        _deltree(os.path.join(root,dir))
1623                for file in files:
1624                        os.remove(os.path.join(root,file))     
1625        os.rmdir(dirname)
1626
1627class InstallationException(Exception):
1628    """
1629    An exception that occurs during add-on installation. Behaves exactly as
1630    :class:`Exception`.
1631
1632    """
1633    pass
1634
1635def install_addon(oaofile, global_install=False, refresh=True):
1636    """
1637    Install an add-on from given .oao package. Installation means unpacking the
1638    .oao file to an appropriate directory (:obj:`Orange.utils.environ.add_ons_dir_user` or
1639    :obj:`Orange.utils.environ.add_ons_dir_sys`, depending on the
1640    :obj:`global_install` parameter), creating an
1641    :class:`OrangeAddOnInstalled` instance and adding this object into the
1642    :obj:`installed_addons` dictionary.
1643   
1644    :param global_install: determines whether the given add-on is to be
1645        installed globally, ie. for all users. Administrative privileges on
1646        the file system are usually needed for that.
1647    :type global_install: boolean
1648   
1649    :param refresh: determines whether add-on list change callback
1650        functions are to be called after the installation process. This
1651        should always be True, except when multiple operations are executed
1652        in a batch.
1653    :type refresh: boolean
1654    """
1655    try:
1656        pack = ZipFile(oaofile, 'r')
1657    except Exception, e:
1658        raise Exception("Unable to unpack the add-on '%s': %s" % (oaofile, e))
1659       
1660    try:
1661        for filename in pack.namelist():
1662            if filename[0]=="\\" or filename[0]=="/" or filename[:2]=="..":
1663                raise InstallationException("Refusing to install unsafe package: it contains file named '%s'!" % filename)
1664       
1665        root = Orange.utils.environ.add_ons_dir if global_install else Orange.utils.environ.add_ons_dir_user
1666       
1667        try:
1668            manifest = _zip_open(pack, 'addon.xml')
1669            addon = OrangeAddOn(manifest)
1670        except Exception, e:
1671            raise Exception("Unable to load add-on descriptor: %s" % e)
1672       
1673        if addon.id in installed_addons:
1674            raise InstallationException("An add-on with this ID is already installed!")
1675       
1676        # Find appropriate directory name for the new add-on.
1677        i = 1
1678        while True:
1679            addon_dir = os.path.join(root,
1680                                     addon.preferred_directory + ("" if i<2 else " (%d)"%i))
1681            if not os.path.exists(addon_dir):
1682                break
1683            i += 1
1684            if i>1000:  # Avoid infinite loop if something goes wrong.
1685                raise InstallationException("Cannot find an appropriate directory name for the new add-on.")
1686       
1687        # Install (unpack) add-on.
1688        try:
1689            os.makedirs(addon_dir)
1690        except OSError, e:
1691            if e.errno==13:  # Permission Denied
1692                raise InstallationException("No write permission for the add-ons directory!")
1693        except Exception, e:
1694                raise Exception("Cannot create a new add-on directory: %s" % e)
1695
1696        try:
1697            if hasattr(pack, "extractall"):
1698                pack.extractall(addon_dir)
1699            else: # Python 2.5
1700                import shutil
1701                for filename in pack.namelist():
1702                    # don't include leading "/" from file name if present
1703                    if filename[0] == '/':
1704                        targetpath = os.path.join(addon_dir, filename[1:])
1705                    else:
1706                        targetpath = os.path.join(addon_dir, filename)
1707                    upperdirs = os.path.dirname(targetpath)
1708                    if upperdirs and not os.path.exists(upperdirs):
1709                        os.makedirs(upperdirs)
1710           
1711                    if filename[-1] == '/':
1712                        if not os.path.isdir(targetpath):
1713                            os.mkdir(targetpath)
1714                        continue
1715           
1716                    source = _zip_open(pack, filename)
1717                    target = file(targetpath, "wb")
1718                    shutil.copyfileobj(source, target)
1719                    source.close()
1720                    target.close()
1721
1722            addon = OrangeAddOnInstalled(addon_dir)
1723            installed_addons[addon.id] = addon
1724        except Exception, e:
1725            try:
1726                _deltree(addon_dir)
1727            except:
1728                pass
1729            raise Exception("Cannot install add-on: %s"%e)
1730       
1731        if refresh:
1732            refresh_addons()
1733    finally:
1734        pack.close()
1735
1736def install_addon_from_repo(addon_in_repo, global_install=False, refresh=True):
1737    """
1738    Retrieve the .oao file from the repository, then call :obj:`install_addon`
1739    on the resulting file, passing it given parameters.
1740   
1741    :param addon_in_repo: add-on in repository to be installed.
1742    :type addon_in_repo: :class:`OrangeAddOnInRepo`
1743    """
1744    try:
1745        tmpfile = urllib.urlretrieve(addon_in_repo.repository.url+"/"+addon_in_repo.filename)[0]
1746    except Exception, e:
1747        raise InstallationException("Unable to download add-on from repository: %s" % e)
1748    install_addon(tmpfile, global_install, refresh)
1749
1750def load_addons():
1751    """
1752    Call :obj:`load_installed_addons_from_dir` on a system-wide add-on
1753    installation directory (:obj:`orngEnviron.addOnsDirSys`) and user-specific
1754    add-on installation directory (:obj:`orngEnviron.addOnsDirUser`).
1755    """
1756    load_installed_addons_from_dir(Orange.utils.environ.add_ons_dir)
1757    load_installed_addons_from_dir(Orange.utils.environ.add_ons_dir_user)
1758
1759def refresh_addons(reload_path=False):
1760    """
1761    Call add-on list change callbacks (ie. functions in
1762    :obj:`addon_refresh_callback`) and, optionally, refresh the python path
1763    (:obj:`sys.path`) with appropriate add-on directories (ie. call
1764    :obj:`addon_refresh_callback`).
1765   
1766    :param reload_path: determines whether python path should be refreshed.
1767    :type reload_path: boolean
1768    """
1769    if reload_path:
1770        add_addon_directories_to_path()
1771    for func in addon_refresh_callback:
1772        func()
1773       
1774# Registered add-ons support       
1775def __read_addons_list(addons_file, systemwide):
1776    if os.path.isfile(addons_file):
1777        name_path_list = [tuple([x.strip() for x in lne.split("\t")])
1778                          for lne in file(addons_file, "rt")]
1779        return [OrangeRegisteredAddOn(name, path, systemwide)
1780                for (name, path) in name_path_list]
1781    else:
1782        return []
1783   
1784def __read_addon_lists(user_only=False):
1785    return __read_addons_list(os.path.join(Orange.utils.environ.orange_settings_dir, "add-ons.txt"),
1786                              False) + ([] if user_only else
1787                                        __read_addons_list(os.path.join(Orange.utils.environ.install_dir, "add-ons.txt"),
1788                                                           True))
1789
1790def __write_addon_lists(addons, user_only=False):
1791    file(os.path.join(Orange.utils.environ.orange_settings_dir, "add-ons.txt"), "wt").write("\n".join(["%s\t%s" % (a.name, a.directory) for a in addons if not a.systemwide]))
1792    if not user_only:
1793        file(os.path.join(Orange.utils.environ.install_dir        , "add-ons.txt"), "wt").write("\n".join(["%s\t%s" % (a.name, a.directory) for a in addons if     a.systemwide]))
1794
1795def register_addon(name, path, add = True, refresh=True, systemwide=False):
1796    """
1797    Register the given path as an registered add-on with a given descriptive
1798    name. The operation is persistent, ie. on next :obj:`load_addons` call the
1799    path will still appear as registered.
1800   
1801    :param name: a descriptive name for the registered add-on.
1802    :type name: str
1803   
1804    :param path: path to be registered.
1805    :type path: str
1806   
1807    :param add: if False, the given path is UNREGISTERED instead of registered.
1808    :type add: boolean
1809   
1810    :param refresh: determines whether callbacks should be called after the
1811        procedure.
1812    :type refresh: boolean
1813   
1814    :param systemwide: determines whether the path is to be registered
1815        system-wide, i.e. for all users. Administrative privileges on the
1816        filesystem are usually needed for that.
1817    :type systemwide: boolean
1818    """
1819    if not add:
1820        unregister_addon(name, path, user_only=not systemwide)
1821    else:
1822        if os.path.isfile(path):
1823            path = os.path.dirname(path)
1824        __write_addon_lists([a for a in __read_addon_lists(user_only=not systemwide)
1825                             if a.name != name and a.directory != path] +\
1826                           ([OrangeRegisteredAddOn(name, path, systemwide)] or []),
1827                             user_only=not systemwide)
1828   
1829        global registered_addons
1830        registered_addons.append( OrangeRegisteredAddOn(name, path, systemwide) )
1831    if refresh:
1832        refresh_addons()
1833
1834def unregister_addon(name, path, user_only=False):
1835    """
1836    Unregister the given path if it has been registered as an add-on with given
1837    descriptive name. The operation is persistent, ie. on next
1838    :obj:`load_addons` call the path will no longer appear as registered.
1839   
1840    :param name: a descriptive name of the registered add-on to be unregistered.
1841    :type name: str
1842   
1843    :param path: path to be unregistered.
1844    :type path: str
1845
1846    :param user_only: determines whether the path to be unregistered is
1847        registered for this user only, ie. not system-wide. Administrative
1848        privileges on the filesystem are usually needed to unregister a
1849        system-wide registered add-on.
1850    :type systemwide: boolean
1851    """
1852    global registered_addons
1853    registered_addons = [ao for ao in registered_addons
1854                         if (ao.name!=name) or (ao.directory!=path) or
1855                         (user_only and ao.systemwide)]
1856    __write_addon_lists([a for a in __read_addon_lists(user_only=user_only)
1857                         if a.name != name and a.directory != path],
1858                         user_only=user_only)
1859
1860
1861def __get_registered_addons():
1862    return {'registered_addons': __read_addon_lists()}
1863
1864load_addons()
1865globals().update(__get_registered_addons())
1866
1867addon_refresh_callback = []
1868globals().update({'addon_refresh_callback': addon_refresh_callback})
1869
1870add_addon_directories_to_path()
1871
1872load_repositories(refresh=False)
Note: See TracBrowser for help on using the repository browser.