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

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

Fixed use of subprocess.STARTF_USESHOWWINDOW flag.

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 xmlrpclib
22import warnings
23import re
24import pkg_resources
25import tempfile
26import tarfile
27import zipfile
28import shutil
29import os
30import sys
31import platform
32import subprocess
33import urllib2
34import urlparse
35import posixpath
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('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.
52
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")
56
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
67
68
69def addons_corrupted():
70    with closing(open_addons()) as addons:
71        return len(addons) == 0
72
73addon_refresh_callback = []
74
75global index
76index = defaultdict(list)
77def rebuild_index():
78    global index
79
80    index = defaultdict(list)
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)
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:
97        with closing(open_addons()) as addons:
98            return addons.keys()
99    for word in words:
100        result.update(index[word])
101    return result
102
103def refresh_available_addons(force=False, progress_callback=None):
104    pypi = xmlrpclib.ServerProxy('http://pypi.python.org/pypi')
105    if progress_callback:
106        progress_callback(1, 0)
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
115    try:
116        import slumber
117        readthedocs = slumber.API(base_url='http://readthedocs.org/api/v1/')
118    except:
119        readthedocs = None
120
121    docs = {}
122    if progress_callback:
123        progress_callback(len(pkg_dict)+1, 1)
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]
130
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)
155
156    rebuild_index()
157
158def load_installed_addons():
159    found = set()
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)
183    rebuild_index()
184
185
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
297def run_setup(setup_script, args):
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()
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
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)
324
325
326def install(name, progress_callback=None):
327    if progress_callback:
328        progress_callback(1, 0)
329
330    with closing(open_addons()) as addons:
331        addon = addons[name.lower()]
332    release_url = addon.release_url
333
334    try:
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
349        extract_archive(package_path, tmpdir)
350
351        setup_py = os.path.join(tmpdir, name + '-' + addon.available_version,
352                                'setup.py')
353
354        if not os.path.isfile(setup_py):
355            raise Exception("Unable to install add-on - it is not properly "
356                            "packed.")
357
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)
362    finally:
363        shutil.rmtree(tmpdir, ignore_errors=True)
364
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()
373    for func in addon_refresh_callback:
374        func()
375
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.")
383
384def upgrade(name, progress_callback=None):
385    install(name, progress_callback)
386
387load_installed_addons()
388
389
390
391# Support for loading legacy "registered" add-ons
392def __read_addons_list(addons_file, systemwide):
393    if os.path.isfile(addons_file):
394        return [tuple([x.strip() for x in lne.split("\t")])
395                for lne in file(addons_file, "rt")]
396    else:
397        return []
398
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)
401
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)
412
413#TODO Show some progress to the user at least during the installation procedure.
Note: See TracBrowser for help on using the repository browser.