1'''
2PyMOL Plugins Engine, Installation Routines
3
4(c) 2011-2012 Thomas Holder, PyMOL OS Fellow
5License: BSD-2-Clause
6
7'''
8
9from __future__ import print_function
10
11import os
12
13# supported file types for installation. Do not support pyc and pyo binaries,
14# we want text files that can be parsed for metadata.
15zip_extensions = ['zip', 'tar.gz']
16supported_extensions = ['py'] + zip_extensions
17
18class InstallationCancelled(Exception):
19    pass
20
21class BadInstallationFile(Exception):
22    pass
23
24def get_default_user_plugin_path():
25    '''
26    User plugin directory defaults to ~/.pymol/startup on Linux and to
27    %APPDATA%\pymol\startup on windows.
28    '''
29    if 'APPDATA' in os.environ:
30        return os.path.join(os.environ['APPDATA'], 'pymol', 'startup')
31    return os.path.expanduser('~/.pymol/startup')
32
33def is_writable(dirname):
34    '''
35    Return True if directory is writable.
36    '''
37    path = os.path.join(dirname, '__check_writable')
38    try:
39        f = open(path, 'wb')
40        f.close()
41        os.remove(path)
42        return True
43    except (IOError, OSError):
44        return False
45
46def cmp_version(v1, v2):
47    '''
48    Compares two version strings. An empty version string is always considered
49    smaller than a non-empty version string.
50
51    Uses distutils.version.StrictVersion to evaluate non-empty version strings.
52    '''
53    if v1 == v2:
54        return 0
55    if v1 == '':
56        return -1
57    if v2 == '':
58        return 1
59    try:
60        from distutils.version import StrictVersion as Version
61        return cmp(Version(v1), Version(v2))
62    except:
63        print(' Warning: Version parsing failed for', v1, 'and/or', v2)
64        return 0
65
66def get_name_and_ext(ofile):
67    '''
68    Given a filename, return module name and file extension.
69
70    Examples:
71    foo-1.0.py -> ('foo', 'py')
72    /foo/bar.tar.gz -> ('bar', 'tar.gz')
73    '''
74    import re
75
76    basename = os.path.basename(ofile)
77    pattern = r'(\w+).*\.(%s)$' % '|'.join(supported_extensions)
78    m = re.match(pattern, basename, re.IGNORECASE)
79
80    if m is None:
81        raise BadInstallationFile('Not a valid plugin filename (%s).' % (basename))
82
83    return m.group(1), m.group(2).lower()
84
85def check_valid_name(name):
86    '''
87    Check if "name" is a valid python module name.
88    '''
89    if '.' in name:
90        raise BadInstallationFile('name must not contain dots (%s).' % repr(name))
91
92def extract_zipfile(ofile, ext):
93    '''
94    Extract zip file to temporary directory
95    '''
96    if ext == 'zip':
97        import zipfile
98        zf = zipfile.ZipFile(ofile)
99    else:
100        import tarfile
101        zf = tarfile.open(ofile)
102        zf.namelist = zf.getnames
103    # make sure pathnames are not absolute
104    cwd = os.getcwd()
105    namelist = zf.namelist()
106    for f in namelist:
107        f = os.path.normpath(f)
108        if not os.path.abspath(f).startswith(cwd):
109            raise BadInstallationFile('ZIP file contains absolute path names')
110    # analyse structure
111    namedict = dict()
112    for f in namelist:
113        x = namedict
114        for part in f.split('/'): # even on windows this is a forward slash (not os.sep)
115            if part != '':
116                x = x.setdefault(part, {})
117    if len(namedict) == 0:
118        raise BadInstallationFile('Archive empty.')
119
120    # case 1: zip/<name>/__init__.py
121    names = [(name,)
122            for name in namedict
123            if '__init__.py' in namedict[name]]
124    if len(names) == 0:
125        # case 2: zip/<name>-<version>/<name>/__init__.py
126        names = [(pname, name)
127                for (pname, pdict) in namedict.items()
128                for name in pdict
129                if '__init__.py' in pdict[name]]
130
131    if len(names) == 0:
132        raise BadInstallationFile('Missing __init__.py')
133    if len(names) > 1:
134        # filter out "tests" directory
135        names = [n for n in names if n[-1] != 'tests']
136    if len(names) > 1:
137        raise BadInstallationFile('Archive must contain a single package.')
138    check_valid_name(names[0][-1])
139
140    # extract
141    import tempfile
142    tempdir = tempfile.mkdtemp()
143    zf.extractall(tempdir)
144
145    return tempdir, names[0]
146
147def get_plugdir(parent=None):
148    '''
149    Get plugin directory, ask user if startup path has more than one entry
150    '''
151    from . import get_startup_path
152    plugdirs = get_startup_path()
153
154    if len(plugdirs) == 1:
155        return plugdirs[0]
156
157    import sys
158    if 'pmg_qt.mimic_tk' in sys.modules:
159        from pymol.Qt import QtWidgets
160        value, result = QtWidgets.QInputDialog.getItem(None,
161            'Select plugin directory',
162            'In which directory should the plugin be installed?', plugdirs)
163        return value if result else ''
164
165    dialog_selection = []
166    def plugdir_callback(result):
167        if result == 'OK':
168            dialog_selection[:] = dialog.getcurselection()
169        dialog.destroy()
170
171    import Pmw
172    dialog = Pmw.SelectionDialog(parent, title='Select plugin directory',
173            buttons = ('OK', 'Cancel'), defaultbutton='OK',
174            scrolledlist_labelpos='n',
175            label_text='In which directory should the plugin be installed?',
176            scrolledlist_items=plugdirs,
177            command=plugdir_callback)
178    dialog.component('scrolledlist').selection_set(0)
179
180    # wait for dialog to be closed
181    dialog.wait_window()
182
183    if not dialog_selection:
184        return ''
185
186    return dialog_selection[0]
187
188def installPluginFromFile(ofile, parent=None, plugdir=None):
189    '''
190    Install plugin from file.
191
192    Takes python (.py) files and archives which contain a python module.
193    '''
194    import shutil
195    from . import startup, PluginInfo
196    from . import get_startup_path, set_startup_path, pref_get
197    from .legacysupport import tkMessageBox, get_tk_focused
198
199    if parent is None:
200        parent = get_tk_focused()
201
202    showinfo = tkMessageBox.showinfo
203    askyesno = tkMessageBox.askyesno
204
205    plugdirs = get_startup_path()
206
207    if not plugdir:
208        plugdir = get_plugdir()
209        if not plugdir:
210            return
211
212    if not is_writable(plugdir):
213        user_plugdir = get_default_user_plugin_path()
214        if not askyesno('Warning',
215                'Unable to write to the plugin directory.\n'
216                'Should a user plugin directory be created at\n' + user_plugdir + '?',
217                parent=parent):
218            showinfo('Error', 'Installation aborted', parent=parent)
219            return
220
221        if not os.path.exists(user_plugdir):
222            try:
223                os.makedirs(user_plugdir)
224            except OSError:
225                showinfo('Error', 'Could not create user plugin directory', parent=parent)
226                return
227
228        plugdir = user_plugdir
229
230    if plugdir not in plugdirs:
231        set_startup_path([plugdir] + get_startup_path(True))
232
233    def remove_if_exists(pathname, ask):
234        '''
235        Remove existing plugin files before reinstallation. Will not remove
236        files if installing into different startup directory.
237        '''
238        if not os.path.exists(pathname):
239            return
240
241        is_dir = os.path.isdir(pathname)
242
243        if ask:
244            if is_dir:
245                msg = 'Directory "%s" already exists, overwrite?' % pathname
246            else:
247                msg = 'File "%s" already exists, overwrite?' % pathname
248            if not tkMessageBox.askyesno('Confirm', msg, parent=parent):
249                raise InstallationCancelled('will not overwrite "%s"' % pathname)
250
251        if is_dir:
252            shutil.rmtree(pathname)
253        else:
254            os.remove(pathname)
255
256    def check_reinstall(name, pathname):
257        from . import plugins
258
259        if name not in plugins:
260            remove_if_exists(pathname, True)
261            return
262
263        v_installed = plugins[name].get_version()
264        v_new = PluginInfo(name, ofile).get_version()
265        c = cmp_version(v_new, v_installed)
266        if c > 0:
267            msg = 'An older version (%s) of this plugin is already installed. Install version %s now?' % (v_installed, v_new)
268        elif c == 0:
269            msg = 'Plugin already installed. Reinstall?'
270        else:
271            msg = 'A newer version (%s) of this plugin is already installed. Install anyway?' % (v_installed)
272
273        if not tkMessageBox.askokcancel('Confirm', msg, parent=parent):
274            raise InstallationCancelled
275
276        remove_if_exists(pathname, False)
277
278    name = "unknown" # fallback for error message
279
280    temppathnames = []
281    try:
282        name, ext = get_name_and_ext(ofile)
283
284        if ext in zip_extensions:
285            # import archive
286
287            tempdir, dirnames = extract_zipfile(ofile, ext)
288            temppathnames.append((tempdir, 1))
289
290            # install
291            name = dirnames[-1]
292            odir = os.path.join(tempdir, *dirnames)
293            ofile = os.path.join(odir, '__init__.py')
294            mod_dir = os.path.join(plugdir, name)
295            check_reinstall(name, mod_dir)
296            check_valid_name(name)
297            shutil.copytree(odir, mod_dir)
298
299            mod_file = os.path.join(mod_dir, '__init__.py')
300
301        elif name == '__init__':
302            # import directory
303            odir = os.path.dirname(ofile)
304            name = os.path.basename(odir)
305            mod_dir = os.path.join(plugdir, name)
306            check_reinstall(name, mod_dir)
307            check_valid_name(name)
308            shutil.copytree(odir, mod_dir)
309
310            mod_file = os.path.join(mod_dir, '__init__.py')
311
312        elif ext == 'py':
313            # import python file
314            mod_file = os.path.join(plugdir, name + '.py')
315            check_reinstall(name, mod_file)
316            check_valid_name(name)
317            shutil.copy(ofile, mod_file)
318
319        else:
320            raise UserWarning('this should never happen')
321
322    except InstallationCancelled:
323        showinfo('Info', 'Installation cancelled', parent=parent)
324        return
325
326    except Exception as e:
327        if pref_get('verbose', False):
328            import traceback
329            traceback.print_exc()
330        msg = 'Unable to install plugin "{}".\n{}'.format(name, e)
331        showinfo('Error', msg, parent=parent)
332        return
333
334    finally:
335        for (pathname, is_dir) in temppathnames:
336            if is_dir:
337                shutil.rmtree(pathname)
338            else:
339                os.remove(pathname)
340
341    prefix = startup.__name__
342    info = PluginInfo(name, mod_file, prefix + '.' + name)
343
344    if info.load(force=1):
345        showinfo('Success', 'Plugin "%s" has been installed.' % name, parent=parent)
346    else:
347        showinfo('Error', 'Plugin "%s" has been installed but initialization failed.' % name, parent=parent)
348
349    if info.get_citation_required():
350        if askyesno('Citation Required', 'This plugin requires citation. Show information now?'
351                '\n\n(You can always get this information from the Plugin Manager, click the "Info" button there)',
352                parent=parent):
353            from .managergui import plugin_info_dialog
354            plugin_info_dialog(parent, info)
355
356# vi:expandtab:smarttab:sw=4
357