1#
2# Gramps - a GTK+/GNOME based genealogy program
3#
4# Copyright (C) 2009 B. Malengier
5#
6# This program is free software; you can redistribute it and/or modify
7# it under the terms of the GNU General Public License as published by
8# the Free Software Foundation; either version 2 of the License, or
9# (at your option) any later version.
10#
11# This program is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14# GNU General Public License for more details.
15#
16# You should have received a copy of the GNU General Public License
17# along with this program; if not, write to the Free Software
18# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
19#
20
21"""
22General utility functions useful for the generic plugin system
23"""
24
25#-------------------------------------------------------------------------
26#
27# Standard Python modules
28#
29#-------------------------------------------------------------------------
30import sys
31import os
32import datetime
33from io import StringIO, BytesIO
34
35#-------------------------------------------------------------------------
36#
37# set up logging
38#
39#-------------------------------------------------------------------------
40import logging
41LOG = logging.getLogger(".gen.plug")
42
43#-------------------------------------------------------------------------
44#
45# Gramps modules
46#
47#-------------------------------------------------------------------------
48from ._pluginreg import make_environment
49from ..const import USER_PLUGINS
50from ...version import VERSION_TUPLE
51from . import BasePluginManager
52from ..utils.configmanager import safe_eval
53from ..config import config
54from ..constfunc import mac
55from ..const import GRAMPS_LOCALE as glocale
56_ = glocale.translation.sgettext
57
58#-------------------------------------------------------------------------
59#
60# Local utility functions for gen.plug
61#
62#-------------------------------------------------------------------------
63def version_str_to_tup(sversion, positions):
64    """
65    Given a string version and positions count, returns a tuple of
66    integers.
67
68    >>> version_str_to_tup("1.02.9", 2)
69    (1, 2)
70    """
71    try:
72        tup = tuple(map(int, sversion.split(".")))
73        tup += (0,) * (positions - len(tup))
74    except:
75        tup = (0,) * positions
76    return tup[:positions]
77
78class newplugin:
79    """
80    Fake newplugin.
81    """
82    def __init__(self):
83        globals()["register_results"].append({})
84    def __setattr__(self, attr, value):
85        globals()["register_results"][-1][attr] = value
86
87def register(ptype, **kwargs):
88    """
89    Fake registration. Side-effect sets register_results to kwargs.
90    """
91    retval = {"ptype": ptype}
92    retval.update(kwargs)
93    # Get the results back to calling function
94    if "register_results" in globals():
95        globals()["register_results"].append(retval)
96    else:
97        globals()["register_results"] = [retval]
98
99class Zipfile:
100    """
101    Class to duplicate the methods of tarfile.TarFile, for Python 2.5.
102    """
103    def __init__(self, buffer):
104        import zipfile
105        self.buffer = buffer
106        self.zip_obj = zipfile.ZipFile(buffer)
107
108    def extractall(self, path, members=None):
109        """
110        Extract all of the files in the zip into path.
111        """
112        names = self.zip_obj.namelist()
113        for name in self.get_paths(names):
114            fullname = os.path.join(path, name)
115            if not os.path.exists(fullname):
116                os.mkdir(fullname)
117        for name in self.get_files(names):
118            fullname = os.path.join(path, name)
119            outfile = file(fullname, 'wb')
120            outfile.write(self.zip_obj.read(name))
121            outfile.close()
122
123    def extractfile(self, name):
124        """
125        Extract a name from the zip file.
126
127        >>> Zipfile(buffer).extractfile("Dir/dile.py").read()
128        <Contents>
129        """
130        class ExtractFile:
131            def __init__(self, zip_obj, name):
132                self.zip_obj = zip_obj
133                self.name = name
134            def read(self):
135                data = self.zip_obj.read(self.name)
136                del self.zip_obj
137                return data
138        return ExtractFile(self.zip_obj, name)
139
140    def close(self):
141        """
142        Close the zip object.
143        """
144        self.zip_obj.close()
145
146    def getnames(self):
147        """
148        Get the files and directories of the zipfile.
149        """
150        return self.zip_obj.namelist()
151
152    def get_paths(self, items):
153        """
154        Get the directories from the items.
155        """
156        return (name for name in items if self.is_path(name) and not self.is_file(name))
157
158    def get_files(self, items):
159        """
160        Get the files from the items.
161        """
162        return (name for name in items if self.is_file(name))
163
164    def is_path(self, name):
165        """
166        Is the name a path?
167        """
168        return os.path.split(name)[0]
169
170    def is_file(self, name):
171        """
172        Is the name a directory?
173        """
174        return os.path.split(name)[1]
175
176def urlopen_maybe_no_check_cert(URL):
177    """
178    Similar to urllib.request.urlopen, but disables certificate
179    verification on Mac.
180    """
181    context = None
182    from urllib.request import urlopen
183    if mac():
184        from ssl import create_default_context, CERT_NONE
185        context = create_default_context()
186        context.check_hostname = False
187        context.verify_mode = CERT_NONE
188    timeout = 10 # seconds
189    fp = None
190    try:
191        fp = urlopen(URL, timeout=timeout, context=context)
192    except TypeError:
193        fp = urlopen(URL, timeout=timeout)
194    return fp
195
196def available_updates():
197    whattypes = config.get('behavior.check-for-addon-update-types')
198
199    LOG.debug("Checking for updated addons...")
200    langs = glocale.get_language_list()
201    langs.append("en")
202    # now we have a list of languages to try:
203    fp = None
204    for lang in langs:
205        URL = ("%s/listings/addons-%s.txt" %
206               (config.get("behavior.addons-url"), lang))
207        LOG.debug("   trying: %s" % URL)
208        try:
209            fp = urlopen_maybe_no_check_cert(URL)
210        except:
211            try:
212                URL = ("%s/listings/addons-%s.txt" %
213                       (config.get("behavior.addons-url"), lang[:2]))
214                fp = urlopen_maybe_no_check_cert(URL)
215            except Exception as err: # some error
216                LOG.warning("Failed to open addon metadata for {lang} {url}: {err}".
217                        format(lang=lang, url=URL, err=err))
218                fp = None
219        if fp and fp.getcode() == 200: # ok
220            break
221
222    pmgr = BasePluginManager.get_instance()
223    addon_update_list = []
224    if fp and fp.getcode() == 200:
225        lines = list(fp.readlines())
226        count = 0
227        for line in lines:
228            line = line.decode('utf-8')
229            try:
230                plugin_dict = safe_eval(line)
231                if type(plugin_dict) != type({}):
232                    raise TypeError("Line with addon metadata is not "
233                                    "a dictionary")
234            except:
235                LOG.warning("Skipped a line in the addon listing: " +
236                         str(line))
237                continue
238            id = plugin_dict["i"]
239            plugin = pmgr.get_plugin(id)
240            if plugin:
241                LOG.debug("Comparing %s > %s" %
242                          (version_str_to_tup(plugin_dict["v"], 3),
243                           version_str_to_tup(plugin.version, 3)))
244                if (version_str_to_tup(plugin_dict["v"], 3) >
245                    version_str_to_tup(plugin.version, 3)):
246                    LOG.debug("   Downloading '%s'..." % plugin_dict["z"])
247                    if "update" in whattypes:
248                        if (not config.get('behavior.do-not-show-previously-seen-addon-updates') or
249                             plugin_dict["i"] not in config.get('behavior.previously-seen-addon-updates')):
250                            addon_update_list.append((_("Updated"),
251                                                      "%s/download/%s" %
252                                                      (config.get("behavior.addons-url"),
253                                                       plugin_dict["z"]),
254                                                      plugin_dict))
255                else:
256                    LOG.debug("   '%s' is ok" % plugin_dict["n"])
257            else:
258                LOG.debug("   '%s' is not installed" % plugin_dict["n"])
259                if "new" in whattypes:
260                    if (not config.get('behavior.do-not-show-previously-seen-addon-updates') or
261                         plugin_dict["i"] not in config.get('behavior.previously-seen-addon-updates')):
262                        addon_update_list.append((_("updates|New"),
263                                                  "%s/download/%s" %
264                                                  (config.get("behavior.addons-url"),
265                                                   plugin_dict["z"]),
266                                                  plugin_dict))
267        config.set("behavior.last-check-for-addon-updates",
268                   datetime.date.today().strftime("%Y/%m/%d"))
269        count += 1
270        if fp:
271            fp.close()
272    else:
273        LOG.debug("Checking Addons Failed")
274    LOG.debug("Done checking!")
275
276    return addon_update_list
277
278def load_addon_file(path, callback=None):
279    """
280    Load an addon from a particular path (from URL or file system).
281    """
282    from urllib.request import urlopen
283    import tarfile
284    if (path.startswith("http://") or
285        path.startswith("https://") or
286        path.startswith("ftp://")):
287        try:
288            fp = urlopen_maybe_no_check_cert(path)
289        except:
290            if callback:
291                callback(_("Unable to open '%s'") % path)
292            return False
293    else:
294        try:
295            fp = open(path)
296        except:
297            if callback:
298                callback(_("Unable to open '%s'") % path)
299            return False
300    try:
301        content = fp.read()
302        buffer = BytesIO(content)
303    except:
304        if callback:
305            callback(_("Error in reading '%s'") % path)
306        return False
307    fp.close()
308    # file_obj is either Zipfile or TarFile
309    if path.endswith(".zip") or path.endswith(".ZIP"):
310        file_obj = Zipfile(buffer)
311    elif path.endswith(".tar.gz") or path.endswith(".tgz"):
312        try:
313            file_obj = tarfile.open(None, fileobj=buffer)
314        except:
315            if callback:
316                callback(_("Error: cannot open '%s'") % path)
317            return False
318    else:
319        if callback:
320            callback(_("Error: unknown file type: '%s'") % path)
321        return False
322    # First, see what versions we have/are getting:
323    good_gpr = set()
324    for gpr_file in [name for name in file_obj.getnames() if name.endswith(".gpr.py")]:
325        if callback:
326            callback((_("Examining '%s'...") % gpr_file) + "\n")
327        contents = file_obj.extractfile(gpr_file).read()
328        # Put a fake register and _ function in environment:
329        env = make_environment(register=register,
330                               newplugin=newplugin,
331                               _=lambda text: text)
332        # clear out the result variable:
333        globals()["register_results"] = []
334        # evaluate the contents:
335        try:
336            exec(contents, env)
337        except Exception as exp:
338            if callback:
339                msg = _("Error in '%s' file: cannot load.") % gpr_file
340                callback("   " + msg + "\n" + str(exp))
341            continue
342        # There can be multiple addons per gpr file:
343        for results in globals()["register_results"]:
344            gramps_target_version = results.get("gramps_target_version", None)
345            id = results.get("id", None)
346            if gramps_target_version:
347                vtup = version_str_to_tup(gramps_target_version, 2)
348                # Is it for the right version of gramps?
349                if vtup == VERSION_TUPLE[0:2]:
350                    # If this version is not installed, or > installed, install it
351                    good_gpr.add(gpr_file)
352                    if callback:
353                        callback("   " + (_("'%s' is for this version of Gramps.") % id)  + "\n")
354                else:
355                    # If the plugin is for another version; inform and do nothing
356                    if callback:
357                        callback("   " + (_("'%s' is NOT for this version of Gramps.") % id)  + "\n")
358                        callback("   " + (_("It is for version %(v1)d.%(v2)d") % {
359                                             'v1': vtup[0],
360                                             'v2': vtup[1]}
361                                          + "\n"))
362                    continue
363            else:
364                # another register function doesn't have gramps_target_version
365                if gpr_file in good_gpr:
366                    s.remove(gpr_file)
367                if callback:
368                    callback("   " + (_("Error: missing gramps_target_version in '%s'...") % gpr_file)  + "\n")
369    registered_count = 0
370    if len(good_gpr) > 0:
371        # Now, install the ok ones
372        try:
373            file_obj.extractall(USER_PLUGINS)
374        except OSError:
375            if callback:
376                callback("OSError installing '%s', skipped!" % path)
377            file_obj.close()
378            return False
379        if callback:
380            callback((_("Installing '%s'...") % path) + "\n")
381        gpr_files = set([os.path.split(os.path.join(USER_PLUGINS, name))[0]
382                         for name in good_gpr])
383        for gpr_file in gpr_files:
384            if callback:
385                callback("   " + (_("Registered '%s'") % gpr_file) + "\n")
386            registered_count += 1
387    file_obj.close()
388    if registered_count:
389        return True
390    else:
391        return False
392
393#-------------------------------------------------------------------------
394#
395# OpenFileOrStdout class
396#
397#-------------------------------------------------------------------------
398class OpenFileOrStdout:
399    """Context manager to open a file or stdout for writing."""
400    def __init__(self, filename, encoding=None, errors=None, newline=None):
401        self.filename = filename
402        self.filehandle = None
403        self.encoding = encoding
404        self.errors = errors
405        self.newline = newline
406
407    def __enter__(self):
408        if self.filename == '-':
409            self.filehandle = sys.stdout
410        else:
411            self.filehandle = open(self.filename, 'w', encoding=self.encoding,
412                                   errors=self.errors, newline=self.newline)
413        return self.filehandle
414
415    def __exit__(self, exc_type, exc_value, traceback):
416        if self.filehandle and self.filename != '-':
417            self.filehandle.close()
418        return False
419
420#-------------------------------------------------------------------------
421#
422# OpenFileOrStdin class
423#
424#-------------------------------------------------------------------------
425class OpenFileOrStdin:
426    """Context manager to open a file or stdin for reading."""
427    def __init__(self, filename, add_mode='', encoding=None):
428        self.filename = filename
429        self.mode = 'r%s' % add_mode
430        self.filehandle = None
431        self.encoding = encoding
432
433    def __enter__(self):
434        if self.filename == '-':
435            self.filehandle = sys.stdin
436        elif self.encoding:
437            self.filehandle = open(self.filename, self.mode, encoding=self.encoding)
438        else:
439            self.filehandle = open(self.filename, self.mode)
440        return self.filehandle
441
442    def __exit__(self, exc_type, exc_value, traceback):
443        if self.filename != '-':
444            self.filehandle.close()
445        return False
446