source: orange/Orange/utils/addons.py @ 11694:d38d2b1084b7

Revision 11694:d38d2b1084b7, 15.5 KB checked in by Ales Erjavec <ales.erjavec@…>, 7 months ago (diff)

Keep all release download urls. Select the source distribution to install.

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 anydbm
22import xmlrpclib
23import warnings
24import re
25import pkg_resources
26import tempfile
27import tarfile
28import zipfile
29import shutil
30import os
31import sys
32import platform
33import subprocess
34import errno
35import urllib2
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(
48    'OrangeAddOn',
49    ['name', 'available_version', 'installed_version', 'summary',
50     'description', 'author', 'docs_url', 'keywords', 'homepage',
51     'package_url', 'release_urls']
52)
53
54ReleaseUrl = namedtuple(
55    "ReleaseUrl",
56    ["filename", "url", "size", "python_version", "packagetype"]
57)
58
59# It'd be great if we could somehow read a list and descriptions of
60# widgets, show them in the dialog and enable search of add-ons
61# based on keywords in widget names and descriptions.
62
63INDEX_RE = "[^a-z0-9-']"  # RE for splitting entries in the search index
64
65AOLIST_FILE = os.path.join(Orange.utils.environ.orange_settings_dir,
66                           "addons_v2.shelve")
67
68
69def open_addons(flag="r"):
70    try:
71        addons = shelve.open(AOLIST_FILE, flag)
72    except anydbm.error as ex:
73        if flag in ["r", "w"] and ex.message.startswith("need 'c'"):
74            # Need to create it it first.
75            s = shelve.open(AOLIST_FILE, "c")
76            s.close()
77            addons = shelve.open(AOLIST_FILE, flag)
78        else:
79            if os.path.isfile(AOLIST_FILE):
80                os.remove(AOLIST_FILE)
81            addons = shelve.open(AOLIST_FILE, 'n')
82    else:
83        # Try to read the whole list and check for sanity.
84        if any(name != name.lower() for name, _ in addons.items()):
85            addons.close()
86            if os.path.isfile(AOLIST_FILE):
87                os.remove(AOLIST_FILE)
88            addons = shelve.open(AOLIST_FILE, 'n')
89
90    return addons
91
92
93def addons_corrupted():
94    with closing(open_addons(flag="r")) as addons:
95        return len(addons) == 0
96
97addon_refresh_callback = []
98
99global index
100index = defaultdict(list)
101def rebuild_index():
102    global index
103
104    index = defaultdict(list)
105    with closing(open_addons(flag="r")) as addons:
106        for name, ao in addons.items():
107            for s in [name, ao.summary, ao.description, ao.author] + (ao.keywords if ao.keywords else []):
108                if not s:
109                    continue
110                words = [word for word in re.split(INDEX_RE, s.lower())
111                         if len(word)>1]
112                for word in words:
113                    for i in range(len(word)):
114                        index[word[:i+1]].append(name)
115
116
117def search_index(query):
118    global index
119    result = set()
120    words = [word for word in re.split(INDEX_RE, query.lower()) if len(word)>1]
121    if not words:
122        with closing(open_addons(flag="r")) as addons:
123            return addons.keys()
124    for word in words:
125        result.update(index[word])
126    return result
127
128
129def refresh_available_addons(force=False, progress_callback=None):
130    pypi = xmlrpclib.ServerProxy('http://pypi.python.org/pypi')
131    if progress_callback:
132        progress_callback(1, 0)
133
134    pkg_dict = {}
135    for data in pypi.search({'keywords': 'orange'}):
136        name = data['name']
137        order = data['_pypi_ordering']
138        if name not in pkg_dict or pkg_dict[name][0] < order:
139            pkg_dict[name] = (order, data['version'])
140
141    try:
142        import slumber
143        readthedocs = slumber.API(base_url='http://readthedocs.org/api/v1/')
144    except:
145        readthedocs = None
146
147    docs = {}
148    if progress_callback:
149        progress_callback(len(pkg_dict) + 1, 1)
150
151    with closing(open_addons(flag="c")) as addons:
152        for i, (name, (_, version)) in enumerate(pkg_dict.items()):
153            installed = addons[name.lower()] if name.lower() in addons else None
154            if force or not installed or installed.available_version != version:
155                try:
156                    data = pypi.release_data(name, version)
157                    urls = pypi.release_urls(name, version)
158                    release_urls = \
159                        [ReleaseUrl(url["filename"], url["url"],
160                                    url["size"], url["python_version"],
161                                    url["packagetype"])
162                         for url in urls]
163                    if readthedocs:
164                        try:
165                            docs = readthedocs.project.get(
166                                slug=name.lower())['objects'][0]
167                        except:
168                            docs = {}
169                    addons[name.lower()] = OrangeAddOn(
170                        name=name,
171                        available_version=data['version'],
172                        installed_version=installed.installed_version if installed else None,
173                        summary=data['summary'],
174                        description=data.get('description', ''),
175                        author=str((data.get('author', '') or '') + ' ' +
176                                   (data.get('author_email', '') or '')).strip(),
177                        docs_url=data.get('docs_url', docs.get('subdomain', '')),
178                        keywords=data.get('keywords', "").split(","),
179                        homepage=data.get('home_page', ''),
180                        package_url=data.get('package_url', ''),
181                        release_urls=release_urls
182                    )
183                except Exception:
184                    import traceback
185                    traceback.print_exc()
186                    warnings.warn(
187                        'Could not load data for the add-on: %s' % name)
188
189            if progress_callback:
190                progress_callback(len(pkg_dict) + 1, i + 2)
191
192    rebuild_index()
193
194
195def load_installed_addons():
196    found = set()
197    with closing(open_addons(flag="c")) as addons:
198        for entry_point in pkg_resources.iter_entry_points(ADDONS_ENTRY_POINT):
199            name = entry_point.dist.project_name
200            version = entry_point.dist.version
201
202            if name.lower() in addons:
203                addons[name.lower()] = addons[name.lower()]._replace(installed_version=version)
204            else:
205                addons[name.lower()] = OrangeAddOn(
206                    name=name,
207                    available_version=None,
208                    installed_version=version,
209                    summary="",
210                    description="",
211                    author="",
212                    docs_url="",
213                    keywords="",
214                    homepage="",
215                    package_url="",
216                    release_urls=[])
217            found.add(name.lower())
218
219        for name in set(addons).difference(found):
220            addons[name.lower()] = addons[name.lower()]._replace(installed_version=None)
221
222    rebuild_index()
223
224
225def open_archive(path, mode="r"):
226    """
227    Return an open archive file object (zipfile.ZipFile or tarfile.TarFile).
228    """
229    _, ext = os.path.splitext(path)
230    if ext == ".zip":
231        # TODO: should it also open .egg, ...
232        archive = zipfile.ZipFile(path, mode)
233
234    elif ext in (".tar", ".gz", ".bz2", ".tgz", ".tbz2", ".tb2"):
235        archive = tarfile.open(path, mode)
236
237    return archive
238
239
240member_info = namedtuple(
241    "member_info",
242    ["info",  # original info object (Tar/ZipInfo)
243     "path",  # filename inside the archive
244     "linkname",  # linkname if applicable
245     "issym",  # True if sym link
246     "islnk",  # True if hardlink
247     ]
248)
249
250
251def archive_members(archive):
252    """
253    Given an open archive return an iterator of `member_info` instances.
254    """
255    if isinstance(archive, zipfile.ZipFile):
256        def converter(info):
257            return member_info(info, info.filename, None, False, False)
258
259        return itertools.imap(converter, archive.infolist())
260    elif isinstance(archive, tarfile.TarFile):
261        def converter(info):
262            return member_info(info, info.name, info.linkname,
263                               info.issym(), info.islnk())
264        return itertools.imap(converter, archive.getmembers())
265    else:
266        raise TypeError
267
268
269def resolve_path(path):
270    """
271    Return a normalized real path.
272    """
273    return os.path.normpath(os.path.realpath(os.path.abspath(path)))
274
275
276def is_badfile(member, base_dir):
277    """
278    Would extracting `member_info` instance write outside of `base_dir`.
279    """
280    path = member.path
281    full_path = resolve_path(os.path.join(base_dir, path))
282    return not full_path.startswith(base_dir)
283
284
285def is_badlink(member, base_dir):
286    """
287    Would extracting `member_info` instance create a link to outside
288    of `base_dir`.
289
290    """
291    if member.issym or member.islnk:
292        dirname = os.path.dirname(member.path)
293        full_path = resolve_path(os.path.join(dirname, member.linkname))
294        return not full_path.startswith(base_dir)
295    else:
296        return False
297
298
299def check_safe(member, base_dir):
300    """
301    Check if member is safe to extract to base_dir or raise an exception.
302    """
303    path = member.path
304    drive, path = os.path.splitdrive(path)
305
306    if drive != "":
307        raise ValueError("Absolute path in archive")
308
309    if path.startswith("/"):
310        raise ValueError("Absolute path in archive")
311
312    base_dir = resolve_path(base_dir)
313
314    if is_badfile(member, base_dir):
315        raise ValueError("Extract outside %r" % base_dir)
316    if is_badlink(member, base_dir):
317        raise ValueError("Link outside %r" % base_dir)
318
319    return True
320
321
322def extract_archive(archive, path="."):
323    """
324    Extract the contents of `archive` to `path`.
325    """
326    if isinstance(archive, basestring):
327        archive = open_archive(archive)
328
329    members = archive_members(archive)
330
331    for member in members:
332        if check_safe(member, path):
333            archive.extract(member.info, path)
334
335
336def run_setup(setup_script, args):
337    """
338    Run `setup_script` with `args` in a subprocess, using
339    :ref:`subprocess.check_output`.
340
341    """
342    source_root = os.path.dirname(setup_script)
343    executable = sys.executable
344    extra_kwargs = {}
345    if os.name == "nt" and os.path.basename(executable) == "pythonw.exe":
346        dirname, _ = os.path.split(executable)
347        executable = os.path.join(dirname, "python.exe")
348        # by default a new console window would show up when executing the
349        # script
350        startupinfo = subprocess.STARTUPINFO()
351        if hasattr(subprocess, "STARTF_USESHOWWINDOW"):
352            startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
353        else:
354            # This flag was missing in inital releases of 2.7
355            startupinfo.dwFlags |= subprocess._subprocess.STARTF_USESHOWWINDOW
356
357        extra_kwargs["startupinfo"] = startupinfo
358
359    process = subprocess.Popen([executable, setup_script] + args,
360                               cwd=source_root,
361                               stdout=subprocess.PIPE,
362                               stderr=subprocess.STDOUT,
363                               bufsize=1,  # line buffered
364                               **extra_kwargs)
365    output = []
366    while process.poll() is None:
367        try:
368            line = process.stdout.readline()
369        except (OSError, IOError) as ex:
370            if ex.errno != errno.EINTR:
371                raise
372        else:
373            output.append(line)
374            print line,
375
376    if process.returncode:
377        raise subprocess.CalledProcessError(
378                  process.returncode,
379                  setup_script,
380                  "".join(output)
381              )
382
383
384def install(name, progress_callback=None):
385    with closing(open_addons(flag="r")) as addons:
386        addon = addons[name.lower()]
387
388    source_urls = [url for url in addon.release_urls
389                   if url.packagetype == "sdist"]
390    release_url = source_urls[0]
391
392    try:
393        tmpdir = tempfile.mkdtemp()
394
395        stream = urllib2.urlopen(release_url.url, timeout=120)
396
397        package_path = os.path.join(tmpdir, release_url.filename)
398
399        progress_cb = (lambda value: progress_callback(value, 0)) \
400                      if progress_callback else None
401        with open(package_path, "wb") as package_file:
402            Orange.utils.copyfileobj(
403                stream, package_file, progress=progress_cb)
404
405        extract_archive(package_path, tmpdir)
406
407        setup_py = os.path.join(tmpdir, name + '-' + addon.available_version,
408                                'setup.py')
409
410        if not os.path.isfile(setup_py):
411            raise Exception("Unable to install add-on - it is not properly "
412                            "packed.")
413
414        switches = []
415        if site.USER_SITE in sys.path:   # we're not in a virtualenv
416            switches.append('--user')
417        run_setup(setup_py, ['install'] + switches)
418    finally:
419        shutil.rmtree(tmpdir, ignore_errors=True)
420
421    for p in list(sys.path):
422        site.addsitedir(p)
423    reload(pkg_resources)
424    for p in list(sys.path):
425        pkg_resources.find_distributions(p)
426    from orngRegistry import load_new_addons
427    load_new_addons()
428    load_installed_addons()
429    for func in addon_refresh_callback:
430        func()
431
432def uninstall(name, progress_callback=None):
433    try:
434        import pip.req
435        ao = pip.req.InstallRequirement(name, None)
436        ao.uninstall(True)
437    except ImportError:
438        raise Exception("Pip is required for add-on uninstallation. "
439                        "Install pip and try again.")
440
441
442def upgrade(name, progress_callback=None):
443    install(name, progress_callback)
444
445
446try:
447    load_installed_addons()
448except Exception as ex:
449    # Do not let an exception propagate during the import phase,
450    # It should not even be called at import only if/when add-ons
451    # db is actually requested.
452    warnings.warn("'load_installed_addons' failed with %s" % ex,
453                  UserWarning)
454
455
456# Support for loading legacy "registered" add-ons
457def __read_addons_list(addons_file, systemwide):
458    if os.path.isfile(addons_file):
459        return [tuple([x.strip() for x in lne.split("\t")])
460                for lne in file(addons_file, "rt")]
461    else:
462        return []
463
464registered = __read_addons_list(os.path.join(Orange.utils.environ.orange_settings_dir, "add-ons.txt"), False) + \
465             __read_addons_list(os.path.join(Orange.utils.environ.install_dir, "add-ons.txt"), True)
466
467if registered:
468    warnings.warn("'add-ons.txt' is deprecated. " +
469                  "Please use setuptools/entry points.",
470                  UserWarning)
471
472for name, path in registered:
473    for p in [os.path.join(path, "widgets", "prototypes"),
474          os.path.join(path, "widgets"),
475          path,
476          os.path.join(path, "lib-%s" % "-".join(( sys.platform, "x86" if (platform.machine()=="")
477          else platform.machine(), ".".join(map(str, sys.version_info[:2])) )) )]:
478        if os.path.isdir(p) and not any([Orange.utils.environ.samepath(p, x)
479                                         for x in sys.path]):
480            if p not in sys.path:
481                sys.path.insert(0, p)
482
483#TODO Show some progress to the user at least during the installation procedure.
Note: See TracBrowser for help on using the repository browser.