source: orange/Orange/utils/addons.py @ 11655:50d8dd042ab3

Revision 11655:50d8dd042ab3, 13.9 KB checked in by Ales Erjavec <ales.erjavec@…>, 8 months ago (diff)

Added support for installing from .zip source packages.

Line 
1from __future__ import absolute_import
2"""
3==============================
4Add-on Management (``addons``)
5==============================
6
7.. index:: add-ons
8
9Orange.utils.addons module provides a framework for Orange add-on management. As
10soon as it is imported, the following initialization takes place: the list of
11installed add-ons is loaded, their directories are added to python path
12(:obj:`sys.path`) the callback list is initialized, the stored repository list is
13loaded. The most important consequence of importing the module is thus the
14injection of add-ons into the namespace.
15
16"""
17
18#TODO Document this module.
19
20import shelve
21import xmlrpclib
22import warnings
23import re
24import pkg_resources
25import tempfile
26import tarfile
27import zipfile
28import shutil
29import os
30import sys
31import platform
32import subprocess
33import urllib2
34import urlparse
35import posixpath
36import site
37import itertools
38
39from collections import namedtuple, defaultdict
40from contextlib import closing
41
42import Orange.utils.environ
43
44ADDONS_ENTRY_POINT="orange.addons"
45
46
47OrangeAddOn = namedtuple('OrangeAddOn', ['name', 'available_version', 'installed_version', 'summary', 'description',
48                                         'author', 'docs_url', 'keywords', 'homepage', 'package_url',
49                                         'release_url', 'release_size', 'python_version'])
50#It'd be great if we could somehow read a list and descriptions of widgets, show them in the dialog and enable
51#search of add-ons based on keywords in widget names and descriptions.
52
53INDEX_RE = "[^a-z0-9-']"  # RE for splitting entries in the search index
54
55AOLIST_FILE = os.path.join(Orange.utils.environ.orange_settings_dir, "addons.shelve")
56
57def open_addons():
58    try:
59        addons = shelve.open(AOLIST_FILE, 'c')
60        if any(name != name.lower() for name, record in addons.items()):  # Try to read the whole list and check for sanity.
61            raise Exception("Corrupted add-on list.")
62    except:
63        if os.path.isfile(AOLIST_FILE):
64            os.remove(AOLIST_FILE)
65        addons = shelve.open(AOLIST_FILE, 'n')
66    return addons
67
68
69def addons_corrupted():
70    with closing(open_addons()) as addons:
71        return len(addons) == 0
72
73addon_refresh_callback = []
74
75global index
76index = defaultdict(list)
77def rebuild_index():
78    global index
79
80    index = defaultdict(list)
81    with closing(open_addons()) as addons:
82        for name, ao in addons.items():
83            for s in [name, ao.summary, ao.description, ao.author] + (ao.keywords if ao.keywords else []):
84                if not s:
85                    continue
86                words = [word for word in re.split(INDEX_RE, s.lower())
87                         if len(word)>1]
88                for word in words:
89                    for i in range(len(word)):
90                        index[word[:i+1]].append(name)
91
92def search_index(query):
93    global index
94    result = set()
95    words = [word for word in re.split(INDEX_RE, query.lower()) if len(word)>1]
96    if not words:
97        with closing(open_addons()) as addons:
98            return addons.keys()
99    for word in words:
100        result.update(index[word])
101    return result
102
103def refresh_available_addons(force=False, progress_callback=None):
104    pypi = xmlrpclib.ServerProxy('http://pypi.python.org/pypi')
105    if progress_callback:
106        progress_callback(1, 0)
107
108    pkg_dict = {}
109    for data in pypi.search({'keywords': 'orange'}):
110        name = data['name']
111        order = data['_pypi_ordering']
112        if name not in pkg_dict or pkg_dict[name][0] < order:
113            pkg_dict[name] = (order, data['version'])
114
115    try:
116        import slumber
117        readthedocs = slumber.API(base_url='http://readthedocs.org/api/v1/')
118    except:
119        readthedocs = None
120
121    docs = {}
122    if progress_callback:
123        progress_callback(len(pkg_dict)+1, 1)
124    with closing(open_addons()) as addons:
125        for i, (name, (_, version)) in enumerate(pkg_dict.items()):
126            if force or name not in addons or addons[name.lower()].available_version != version:
127                try:
128                    data = pypi.release_data(name, version)
129                    rel = pypi.release_urls(name, version)[0]
130
131                    if readthedocs:
132                        try:
133                            docs = readthedocs.project.get(slug=name.lower())['objects'][0]
134                        except:
135                            docs = {}
136                    addons[name.lower()] = OrangeAddOn(name = name,
137                                               available_version = data['version'],
138                                               installed_version = addons[name.lower()].installed_version if name.lower() in addons else None,
139                                               summary = data['summary'],
140                                               description = data.get('description', ''),
141                                               author = str((data.get('author', '') or '') + ' ' + (data.get('author_email', '') or '')).strip(),
142                                               docs_url = data.get('docs_url', docs.get('subdomain', '')),
143                                               keywords = data.get('keywords', "").split(","),
144                                               homepage = data.get('home_page', ''),
145                                               package_url = data.get('package_url', ''),
146                                               release_url = rel.get('url', None),
147                                               release_size = rel.get('size', -1),
148                                               python_version = rel.get('python_version', None))
149                except Exception, e:
150                    import traceback
151                    traceback.print_exc()
152                    warnings.warn('Could not load data for the following add-on: %s'%name)
153            if progress_callback:
154                progress_callback(len(pkg_dict)+1, i+2)
155
156    rebuild_index()
157
158def load_installed_addons():
159    found = set()
160    with closing(open_addons()) as addons:
161        for entry_point in pkg_resources.iter_entry_points(ADDONS_ENTRY_POINT):
162            name, version = entry_point.dist.project_name, entry_point.dist.version
163            #TODO We could import setup.py from entry_point.location and load descriptions and such ...
164            if name.lower() in addons:
165                addons[name.lower()] = addons[name.lower()]._replace(installed_version = version)
166            else:
167                addons[name.lower()] = OrangeAddOn(name = name,
168                    available_version = None,
169                    installed_version = version,
170                    summary = "",
171                    description = "",
172                    author = "",
173                    docs_url = "",
174                    keywords = "",
175                    homepage = "",
176                    package_url = "",
177                    release_url = "",
178                    release_size = None,
179                    python_version = None)
180            found.add(name.lower())
181        for name in set(addons).difference(found):
182            addons[name.lower()] = addons[name.lower()]._replace(installed_version = None)
183    rebuild_index()
184
185
186def open_archive(path, mode="r"):
187    """
188    Return an open archive file object (zipfile.ZipFile or tarfile.TarFile).
189    """
190    _, ext = os.path.splitext(path)
191    if ext == ".zip":
192        # TODO: should it also open .egg, ...
193        archive = zipfile.ZipFile(path, mode)
194
195    elif ext in (".tar", ".gz", ".bz2", ".tgz", ".tbz2", ".tb2"):
196        archive = tarfile.open(path, mode)
197
198    return archive
199
200
201member_info = namedtuple(
202    "member_info",
203    ["info",  # original info object (Tar/ZipInfo)
204     "path",  # filename inside the archive
205     "linkname",  # linkname if applicable
206     "issym",  # True if sym link
207     "islnk",  # True if hardlink
208     ]
209)
210
211
212def archive_members(archive):
213    """
214    Given an open archive return an iterator of `member_info` instances.
215    """
216    if isinstance(archive, zipfile.ZipFile):
217        def converter(info):
218            return member_info(info, info.filename, None, False, False)
219
220        return itertools.imap(converter, archive.infolist())
221    elif isinstance(archive, tarfile.TarFile):
222        def converter(info):
223            return member_info(info, info.name, info.linkname,
224                               info.issym(), info.islnk())
225        return itertools.imap(converter, archive.getmembers())
226    else:
227        raise TypeError
228
229
230def resolve_path(path):
231    """
232    Return a normalized real path.
233    """
234    return os.path.normpath(os.path.realpath(os.path.abspath(path)))
235
236
237def is_badfile(member, base_dir):
238    """
239    Would extracting `member_info` instance write outside of `base_dir`.
240    """
241    path = member.path
242    full_path = resolve_path(os.path.join(base_dir, path))
243    return not full_path.startswith(base_dir)
244
245
246def is_badlink(member, base_dir):
247    """
248    Would extracting `member_info` instance create a link to outside
249    of `base_dir`.
250
251    """
252    if member.issym or member.islnk:
253        dirname = os.path.dirname(member.path)
254        full_path = resolve_path(os.path.join(dirname, member.linkname))
255        return not full_path.startswith(base_dir)
256    else:
257        return False
258
259
260def check_safe(member, base_dir):
261    """
262    Check if member is safe to extract to base_dir or raise an exception.
263    """
264    path = member.path
265    drive, path = os.path.splitdrive(path)
266
267    if drive != "":
268        raise ValueError("Absolute path in archive")
269
270    if path.startswith("/"):
271        raise ValueError("Absolute path in archive")
272
273    base_dir = resolve_path(base_dir)
274
275    if is_badfile(member, base_dir):
276        raise ValueError("Extract outside %r" % base_dir)
277    if is_badlink(member, base_dir):
278        raise ValueError("Link outside %r" % base_dir)
279
280    return True
281
282
283def extract_archive(archive, path="."):
284    """
285    Extract the contents of `archive` to `path`.
286    """
287    if isinstance(archive, basestring):
288        archive = open_archive(archive)
289
290    members = archive_members(archive)
291
292    for member in members:
293        if check_safe(member, path):
294            archive.extract(member.info, path)
295
296
297def run_setup(setup_script, args):
298    """
299    Run `setup_script` with `args` in a subprocess, using
300    :ref:`subprocess.check_output`.
301
302    """
303    source_root = os.path.dirname(setup_script)
304    executable = sys.executable
305    extra_kwargs = {}
306    if os.name == "nt" and os.path.basename(executable) == "pythonw.exe":
307        dirname, _ = os.path.split(executable)
308        executable = os.path.join(dirname, "python.exe")
309        # by default a new console window would show up when executing the
310        # script
311        startupinfo = subprocess.STARTUPINFO()
312        startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
313        extra_kwargs["startupinfo"] = startupinfo
314
315    subprocess.check_output([executable, setup_script] + args,
316                             cwd=source_root,
317                             stderr=subprocess.STDOUT,
318                             **extra_kwargs)
319
320
321def install(name, progress_callback=None):
322    if progress_callback:
323        progress_callback(1, 0)
324
325    with closing(open_addons()) as addons:
326        addon = addons[name.lower()]
327    release_url = addon.release_url
328
329    try:
330        tmpdir = tempfile.mkdtemp()
331
332        stream = urllib2.urlopen(release_url, timeout=120)
333
334        parsed_url = urlparse.urlparse(release_url)
335        package_name = posixpath.basename(parsed_url.path)
336        package_path = os.path.join(tmpdir, package_name)
337
338        progress_cb = (lambda value: progress_callback(value, 0)) \
339                      if progress_callback else None
340        with open(package_path, "wb") as package_file:
341            Orange.utils.copyfileobj(
342                stream, package_file, progress=progress_cb)
343
344        extract_archive(package_path, tmpdir)
345
346        setup_py = os.path.join(tmpdir, name + '-' + addon.available_version,
347                                'setup.py')
348
349        if not os.path.isfile(setup_py):
350            raise Exception("Unable to install add-on - it is not properly "
351                            "packed.")
352
353        switches = []
354        if site.USER_SITE in sys.path:   # we're not in a virtualenv
355            switches.append('--user')
356        run_setup(setup_py, ['install'] + switches)
357    finally:
358        shutil.rmtree(tmpdir, ignore_errors=True)
359
360    for p in list(sys.path):
361        site.addsitedir(p)
362    reload(pkg_resources)
363    for p in list(sys.path):
364        pkg_resources.find_distributions(p)
365    from orngRegistry import load_new_addons
366    load_new_addons()
367    load_installed_addons()
368    for func in addon_refresh_callback:
369        func()
370
371def uninstall(name, progress_callback=None):
372    try:
373        import pip.req
374        ao = pip.req.InstallRequirement(name, None)
375        ao.uninstall(True)
376    except ImportError:
377        raise Exception("Pip is required for add-on uninstallation. Install pip and try again.")
378
379def upgrade(name, progress_callback=None):
380    install(name, progress_callback)
381
382load_installed_addons()
383
384
385
386# Support for loading legacy "registered" add-ons
387def __read_addons_list(addons_file, systemwide):
388    if os.path.isfile(addons_file):
389        return [tuple([x.strip() for x in lne.split("\t")])
390                for lne in file(addons_file, "rt")]
391    else:
392        return []
393
394registered = __read_addons_list(os.path.join(Orange.utils.environ.orange_settings_dir, "add-ons.txt"), False) + \
395             __read_addons_list(os.path.join(Orange.utils.environ.install_dir, "add-ons.txt"), True)
396
397for name, path in registered:
398    for p in [os.path.join(path, "widgets", "prototypes"),
399          os.path.join(path, "widgets"),
400          path,
401          os.path.join(path, "lib-%s" % "-".join(( sys.platform, "x86" if (platform.machine()=="")
402          else platform.machine(), ".".join(map(str, sys.version_info[:2])) )) )]:
403        if os.path.isdir(p) and not any([Orange.utils.environ.samepath(p, x)
404                                         for x in sys.path]):
405            if p not in sys.path:
406                sys.path.insert(0, p)
407
408#TODO Show some progress to the user at least during the installation procedure.
Note: See TracBrowser for help on using the repository browser.