source: orange/Orange/utils/addons.py @ 11656:9b2551185ee8

Revision 11656:9b2551185ee8, 14.1 KB checked in by Ales Erjavec <ales.erjavec@…>, 8 months ago (diff)

Fixed use of subprocess.STARTF_USESHOWWINDOW flag.

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
21import xmlrpclib
22import warnings
[8042]23import re
[11018]24import pkg_resources
25import tempfile
26import tarfile
[11655]27import zipfile
[11018]28import shutil
[8042]29import os
30import sys
31import platform
[11094]32import subprocess
[11653]33import urllib2
34import urlparse
35import posixpath
36import site
[11655]37import itertools
[11094]38
[11018]39from collections import namedtuple, defaultdict
[11071]40from contextlib import closing
[8042]41
[10580]42import Orange.utils.environ
[8042]43
[11018]44ADDONS_ENTRY_POINT="orange.addons"
[8042]45
46
[11018]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.
[8042]52
[11018]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")
[11653]56
[11071]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
[11018]67
[11024]68
[11654]69def addons_corrupted():
70    with closing(open_addons()) as addons:
71        return len(addons) == 0
[11597]72
[11018]73addon_refresh_callback = []
74
75global index
76index = defaultdict(list)
77def rebuild_index():
78    global index
79
80    index = defaultdict(list)
[11071]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)
[11018]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:
[11071]97        with closing(open_addons()) as addons:
98            return addons.keys()
[11018]99    for word in words:
100        result.update(index[word])
101    return result
102
[11020]103def refresh_available_addons(force=False, progress_callback=None):
[11018]104    pypi = xmlrpclib.ServerProxy('http://pypi.python.org/pypi')
[11020]105    if progress_callback:
106        progress_callback(1, 0)
[11018]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
[8042]115    try:
[11018]116        import slumber
117        readthedocs = slumber.API(base_url='http://readthedocs.org/api/v1/')
[8042]118    except:
[11018]119        readthedocs = None
[8042]120
[11018]121    docs = {}
[11020]122    if progress_callback:
123        progress_callback(len(pkg_dict)+1, 1)
[11071]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]
[8042]130
[11071]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)
[8042]155
[11018]156    rebuild_index()
[8042]157
[11018]158def load_installed_addons():
159    found = set()
[11071]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)
[11018]183    rebuild_index()
184
[11094]185
[11655]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
[11018]297def run_setup(setup_script, args):
[11094]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()
[11656]312        if hasattr(subprocess, "STARTF_USESHOWWINDOW"):
313            startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
314        else:
315            # This flag was missing in inital releases of 2.7
316            startupinfo.dwFlags |= subprocess._subprocess.STARTF_USESHOWWINDOW
317
[11094]318        extra_kwargs["startupinfo"] = startupinfo
319
320    subprocess.check_output([executable, setup_script] + args,
321                             cwd=source_root,
322                             stderr=subprocess.STDOUT,
323                             **extra_kwargs)
[11018]324
325
[11021]326def install(name, progress_callback=None):
327    if progress_callback:
328        progress_callback(1, 0)
[11653]329
330    with closing(open_addons()) as addons:
331        addon = addons[name.lower()]
332    release_url = addon.release_url
[11018]333
334    try:
[11653]335        tmpdir = tempfile.mkdtemp()
336
337        stream = urllib2.urlopen(release_url, timeout=120)
338
339        parsed_url = urlparse.urlparse(release_url)
340        package_name = posixpath.basename(parsed_url.path)
341        package_path = os.path.join(tmpdir, package_name)
342
343        progress_cb = (lambda value: progress_callback(value, 0)) \
344                      if progress_callback else None
345        with open(package_path, "wb") as package_file:
346            Orange.utils.copyfileobj(
347                stream, package_file, progress=progress_cb)
348
[11655]349        extract_archive(package_path, tmpdir)
350
[11653]351        setup_py = os.path.join(tmpdir, name + '-' + addon.available_version,
352                                'setup.py')
[8042]353
[11018]354        if not os.path.isfile(setup_py):
[11653]355            raise Exception("Unable to install add-on - it is not properly "
356                            "packed.")
[8042]357
[11094]358        switches = []
359        if site.USER_SITE in sys.path:   # we're not in a virtualenv
360            switches.append('--user')
361        run_setup(setup_py, ['install'] + switches)
[11018]362    finally:
363        shutil.rmtree(tmpdir, ignore_errors=True)
[8042]364
[11018]365    for p in list(sys.path):
366        site.addsitedir(p)
367    reload(pkg_resources)
368    for p in list(sys.path):
369        pkg_resources.find_distributions(p)
370    from orngRegistry import load_new_addons
371    load_new_addons()
372    load_installed_addons()
[8042]373    for func in addon_refresh_callback:
374        func()
[11018]375
[11025]376def uninstall(name, progress_callback=None):
377    try:
378        import pip.req
379        ao = pip.req.InstallRequirement(name, None)
380        ao.uninstall(True)
381    except ImportError:
382        raise Exception("Pip is required for add-on uninstallation. Install pip and try again.")
[11018]383
[11021]384def upgrade(name, progress_callback=None):
385    install(name, progress_callback)
[11018]386
387load_installed_addons()
388
389
390
391# Support for loading legacy "registered" add-ons
[8042]392def __read_addons_list(addons_file, systemwide):
393    if os.path.isfile(addons_file):
[11018]394        return [tuple([x.strip() for x in lne.split("\t")])
395                for lne in file(addons_file, "rt")]
[8042]396    else:
397        return []
398
[11018]399registered = __read_addons_list(os.path.join(Orange.utils.environ.orange_settings_dir, "add-ons.txt"), False) + \
400             __read_addons_list(os.path.join(Orange.utils.environ.install_dir, "add-ons.txt"), True)
[8042]401
[11018]402for name, path in registered:
403    for p in [os.path.join(path, "widgets", "prototypes"),
404          os.path.join(path, "widgets"),
405          path,
406          os.path.join(path, "lib-%s" % "-".join(( sys.platform, "x86" if (platform.machine()=="")
407          else platform.machine(), ".".join(map(str, sys.version_info[:2])) )) )]:
408        if os.path.isdir(p) and not any([Orange.utils.environ.samepath(p, x)
409                                         for x in sys.path]):
410            if p not in sys.path:
411                sys.path.insert(0, p)
[8042]412
[11018]413#TODO Show some progress to the user at least during the installation procedure.
Note: See TracBrowser for help on using the repository browser.