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.

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
38import pipes
39
40from collections import namedtuple, defaultdict
41from contextlib import closing
42
43import Orange.utils.environ
44
45ADDONS_ENTRY_POINT = "orange.addons"
46
47
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.
63
64INDEX_RE = "[^a-z0-9-']"  # RE for splitting entries in the search index
65
66AOLIST_FILE = os.path.join(Orange.utils.environ.orange_settings_dir,
67                           "addons_v2.shelve")
68
69
70def open_addons(flag="r"):
71    try:
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
91    return addons
92
93
94def addons_corrupted():
95    with closing(open_addons(flag="r")) as addons:
96        return len(addons) == 0
97
98addon_refresh_callback = []
99
100global index
101index = defaultdict(list)
102def rebuild_index():
103    global index
104
105    index = defaultdict(list)
106    with closing(open_addons(flag="r")) as addons:
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)
116
117
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:
123        with closing(open_addons(flag="r")) as addons:
124            return addons.keys()
125    for word in words:
126        result.update(index[word])
127    return result
128
129
130def refresh_available_addons(force=False, progress_callback=None):
131    pypi = xmlrpclib.ServerProxy('http://pypi.python.org/pypi')
132    if progress_callback:
133        progress_callback(1, 0)
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
142    try:
143        import slumber
144        readthedocs = slumber.API(base_url='http://readthedocs.org/api/v1/')
145    except:
146        readthedocs = None
147
148    docs = {}
149    if progress_callback:
150        progress_callback(len(pkg_dict) + 1, 1)
151
152    with closing(open_addons(flag="c")) as addons:
153        for i, (name, (_, version)) in enumerate(pkg_dict.items()):
154            installed = addons[name.lower()] if name.lower() in addons else None
155            if force or not installed or installed.available_version != version:
156                try:
157                    data = pypi.release_data(name, version)
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]
164                    if readthedocs:
165                        try:
166                            docs = readthedocs.project.get(
167                                slug=name.lower())['objects'][0]
168                        except:
169                            docs = {}
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:
185                    import traceback
186                    traceback.print_exc()
187                    warnings.warn(
188                        'Could not load data for the add-on: %s' % name)
189
190            if progress_callback:
191                progress_callback(len(pkg_dict) + 1, i + 2)
192
193    rebuild_index()
194
195
196def load_installed_addons():
197    found = set()
198    with closing(open_addons(flag="c")) as addons:
199        for entry_point in pkg_resources.iter_entry_points(ADDONS_ENTRY_POINT):
200            name = entry_point.dist.project_name
201            version = entry_point.dist.version
202
203            if name.lower() in addons:
204                addons[name.lower()] = addons[name.lower()]._replace(installed_version=version)
205            else:
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=[])
218            found.add(name.lower())
219
220        for name in set(addons).difference(found):
221            addons[name.lower()] = addons[name.lower()]._replace(installed_version=None)
222
223    rebuild_index()
224
225
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
337def run_setup(setup_script, args):
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()
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
358        extra_kwargs["startupinfo"] = startupinfo
359
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              )
383
384
385def install(name, progress_callback=None):
386    with closing(open_addons(flag="r")) as addons:
387        addon = addons[name.lower()]
388
389    source_urls = [url for url in addon.release_urls
390                   if url.packagetype == "sdist"]
391    release_url = source_urls[0]
392
393    try:
394        tmpdir = tempfile.mkdtemp()
395
396        stream = urllib2.urlopen(release_url.url, timeout=120)
397
398        package_path = os.path.join(tmpdir, release_url.filename)
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
406        extract_archive(package_path, tmpdir)
407
408        setup_py = os.path.join(tmpdir, name + '-' + addon.available_version,
409                                'setup.py')
410
411        if not os.path.isfile(setup_py):
412            raise Exception("Unable to install add-on - it is not properly "
413                            "packed.")
414
415        switches = []
416        if not hasattr(sys, "real_prefix"):
417            # we're not in a virtualenv
418            switches.append('--user')
419        run_setup(setup_py, ['install'] + switches)
420    finally:
421        shutil.rmtree(tmpdir, ignore_errors=True)
422
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()
431    for func in addon_refresh_callback:
432        func()
433
434
435def easy_install_process(args, bufsize=-1):
436    from setuptools.command import easy_install
437    # Check if easy_install supports '--user' switch
438    options = [opt[0] for opt in easy_install.easy_install.user_options]
439    has_user_site = "user" in options
440
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?)
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
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:
503        raise Exception("Pip is required for add-on uninstallation. "
504                        "Install pip and try again.")
505
506
507def upgrade(name, progress_callback=None):
508    install(name, progress_callback)
509
510
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)
519
520
521# Support for loading legacy "registered" add-ons
522def __read_addons_list(addons_file, systemwide):
523    if os.path.isfile(addons_file):
524        return [tuple([x.strip() for x in lne.split("\t")])
525                for lne in file(addons_file, "rt")]
526    else:
527        return []
528
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)
531
532if registered:
533    warnings.warn("'add-ons.txt' is deprecated. " +
534                  "Please use setuptools/entry points.",
535                  UserWarning)
536
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)
547
548#TODO Show some progress to the user at least during the installation procedure.
Note: See TracBrowser for help on using the repository browser.