source: orange/Orange/utils/addons.py @ 11886:a5fbbe6bcc0b

Revision 11886:a5fbbe6bcc0b, 17.2 KB checked in by Ales Erjavec <ales.erjavec@…>, 2 weeks ago (diff)

Attempt to recreate the shelve dbm if importing the current implementation fails.

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