source: orange/Orange/utils/addons.py @ 11732:268717fbedb4

Revision 11732:268717fbedb4, 17.4 KB checked in by Ales Erjavec <ales.erjavec@…>, 6 months ago (diff)

Changed the way virtualenv is detected.

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