1# -*- coding: utf-8 -*-
2# Copyright(C) 2010-2011 Romain Bignon
3#
4# This file is part of weboob.
5#
6# weboob is free software: you can redistribute it and/or modify
7# it under the terms of the GNU Lesser General Public License as published by
8# the Free Software Foundation, either version 3 of the License, or
9# (at your option) any later version.
10#
11# weboob 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 Lesser General Public License for more details.
15#
16# You should have received a copy of the GNU Lesser General Public License
17# along with weboob. If not, see <http://www.gnu.org/licenses/>.
18
19
20import os
21from copy import copy
22from threading import RLock
23
24from weboob.capabilities.base import BaseObject, Capability, FieldNotFound, NotAvailable, NotLoaded
25from weboob.exceptions import ModuleInstallError
26from weboob.tools.compat import basestring, getproxies
27from weboob.tools.log import getLogger
28from weboob.tools.json import json
29from weboob.tools.misc import iter_fields
30from weboob.tools.value import ValuesDict
31
32__all__ = ['BackendStorage', 'BackendConfig', 'Module']
33
34
35class BackendStorage(object):
36    """
37    This is an abstract layer to store data in storages (:mod:`weboob.tools.storage`)
38    easily.
39
40    It is instancied automatically in constructor of :class:`Module`, in the
41    :attr:`Module.storage` attribute.
42
43    :param name: name of backend
44    :param storage: storage object
45    :type storage: :class:`weboob.tools.storage.IStorage`
46    """
47
48    def __init__(self, name, storage):
49        self.name = name
50        self.storage = storage
51
52    def set(self, *args):
53        """
54        Set value in the storage.
55
56        Example:
57
58        >>> from weboob.tools.storage import StandardStorage
59        >>> backend = BackendStorage('blah', StandardStorage('/tmp/cfg'))
60        >>> backend.storage.set('config', 'nb_of_threads', 10)  # doctest: +SKIP
61        >>>
62
63        :param args: the path where to store value
64        """
65        if self.storage:
66            return self.storage.set('backends', self.name, *args)
67
68    def delete(self, *args):
69        """
70        Delete a value from the storage.
71
72        :param args: path to delete.
73        """
74        if self.storage:
75            return self.storage.delete('backends', self.name, *args)
76
77    def get(self, *args, **kwargs):
78        """
79        Get a value or a dict of values in storage.
80
81        Example:
82
83        >>> from weboob.tools.storage import StandardStorage
84        >>> backend = BackendStorage('blah', StandardStorage('/tmp/cfg'))
85        >>> backend.storage.get('config', 'nb_of_threads')  # doctest: +SKIP
86        10
87        >>> backend.storage.get('config', 'unexistant', 'path', default='lol')  # doctest: +SKIP
88        'lol'
89        >>> backend.storage.get('config')  # doctest: +SKIP
90        {'nb_of_threads': 10, 'other_things': 'blah'}
91
92        :param args: path to get
93        :param default: if specified, default value when path is not found
94        """
95        if self.storage:
96            return self.storage.get('backends', self.name, *args, **kwargs)
97        else:
98            return kwargs.get('default', None)
99
100    def load(self, default):
101        """
102        Load storage.
103
104        It is made automatically when your backend is created, and use the
105        ``STORAGE`` class attribute as default.
106
107        :param default: this is the default tree if storage is empty
108        :type default: :class:`dict`
109        """
110        if self.storage:
111            return self.storage.load('backends', self.name, default)
112
113    def save(self):
114        """
115        Save storage.
116        """
117        if self.storage:
118            return self.storage.save('backends', self.name)
119
120
121class BackendConfig(ValuesDict):
122    """
123    Configuration of a backend.
124
125    This class is firstly instanced as a :class:`weboob.tools.value.ValuesDict`,
126    containing some :class:`weboob.tools.value.Value` (and derivated) objects.
127
128    Then, using the :func:`load` method will load configuration from file and
129    create a copy of the :class:`BackendConfig` object with the loaded values.
130    """
131    modname = None
132    instname = None
133    weboob = None
134
135    def load(self, weboob, modname, instname, config, nofail=False):
136        """
137        Load configuration from dict to create an instance.
138
139        :param weboob: weboob object
140        :type weboob: :class:`weboob.core.ouiboube.Weboob`
141        :param modname: name of the module
142        :type modname: :class:`str`
143        :param instname: name of this backend
144        :type instname: :class:`str`
145        :param params: parameters to load
146        :type params: :class:`dict`
147        :param nofail: if true, this call can't fail
148        :type nofail: :class:`bool`
149        :rtype: :class:`BackendConfig`
150        """
151        cfg = BackendConfig()
152        cfg.modname = modname
153        cfg.instname = instname
154        cfg.weboob = weboob
155        for name, field in self.items():
156            value = config.get(name, None)
157
158            if value is None:
159                if not nofail and field.required:
160                    raise Module.ConfigError('Backend(%s): Configuration error: Missing parameter "%s" (%s)'
161                                                  % (cfg.instname, name, field.description))
162                value = field.default
163
164            field = copy(field)
165            try:
166                field.load(cfg.instname, value, cfg.weboob.requests)
167            except ValueError as v:
168                if not nofail:
169                    raise Module.ConfigError(
170                        'Backend(%s): Configuration error for field "%s": %s' % (cfg.instname, name, v))
171
172            cfg[name] = field
173        return cfg
174
175    def dump(self):
176        """
177        Dump config in a dictionary.
178
179        :rtype: :class:`dict`
180        """
181        settings = {}
182        for name, value in self.items():
183            if not value.transient:
184                settings[name] = value.dump()
185        return settings
186
187    def save(self, edit=True, params=None):
188        """
189        Save backend config.
190
191        :param edit: if true, it changes config of an existing backend
192        :type edit: :class:`bool`
193        :param params: if specified, params to merge with the ones of the current object
194        :type params: :class:`dict`
195        """
196        assert self.modname is not None
197        assert self.instname is not None
198        assert self.weboob is not None
199
200        dump = self.dump()
201        if params is not None:
202            dump.update(params)
203
204        if edit:
205            self.weboob.backends_config.edit_backend(self.instname, dump)
206        else:
207            self.weboob.backends_config.add_backend(self.instname, self.modname, dump)
208
209
210class Module(object):
211    """
212    Base class for modules.
213
214    You may derivate it, and also all capabilities you want to implement.
215
216    :param weboob: weboob instance
217    :type weboob: :class:`weboob.core.ouiboube.Weboob`
218    :param name: name of backend
219    :type name: :class:`str`
220    :param config: configuration of backend
221    :type config: :class:`dict`
222    :param storage: storage object
223    :type storage: :class:`weboob.tools.storage.IStorage`
224    :param logger: logger
225    :type logger: :class:`logging.Logger`
226    """
227    # Module name.
228    NAME = None
229    """Name of the maintainer of this module."""
230
231    MAINTAINER = u'<unspecified>'
232
233    EMAIL = '<unspecified>'
234    """Email address of the maintainer."""
235
236    VERSION = '<unspecified>'
237    """Version of module (for information only)."""
238
239    DESCRIPTION = '<unspecified>'
240    """Description"""
241
242    # License of this module.
243    LICENSE = '<unspecified>'
244
245    CONFIG = BackendConfig()
246    """Configuration required for backends.
247
248    Values must be weboob.tools.value.Value objects.
249    """
250
251    STORAGE = {}
252    """Storage"""
253
254    BROWSER = None
255    """Browser class"""
256
257    ICON = None
258    """URL to an optional icon.
259
260    If you want to create your own icon, create a 'favicon.png' icon in
261    the module's directory, and keep the ICON value to None.
262    """
263
264    OBJECTS = {}
265    """Supported objects to fill
266
267    The key is the class and the value the method to call to fill
268    Method prototype: method(object, fields)
269    When the method is called, fields are only the one which are
270    NOT yet filled.
271    """
272
273    class ConfigError(Exception):
274        """
275        Raised when the config can't be loaded.
276        """
277
278    def __enter__(self):
279        self.lock.acquire()
280
281    def __exit__(self, t, v, tb):
282        self.lock.release()
283
284    def __repr__(self):
285        return "<Backend %r>" % self.name
286
287    def __new__(cls, *args, **kwargs):
288        """ Accept any arguments, necessary for AbstractModule __new__ override.
289
290        AbstractModule, in its overridden __new__, removes itself from class hierarchy
291        so its __new__ is called only once. In python 3, default (object) __new__ is
292        then used for next instantiations but it's a slot/"fixed" version supporting
293        only one argument (type to instanciate).
294        """
295        return object.__new__(cls)
296
297    def __init__(self, weboob, name, config=None, storage=None, logger=None, nofail=False):
298        self.logger = getLogger(name, parent=logger)
299        self.weboob = weboob
300        self.name = name
301        self.lock = RLock()
302        if config is None:
303            config = {}
304
305        # Private fields (which start with '_')
306        self._private_config = dict((key, value) for key, value in config.items() if key.startswith('_'))
307
308        # Load configuration of backend.
309        self.config = self.CONFIG.load(weboob, self.NAME, self.name, config, nofail)
310
311        self.storage = BackendStorage(self.name, storage)
312        self.storage.load(self.STORAGE)
313
314    def dump_state(self):
315        if hasattr(self.browser, 'dump_state'):
316            self.storage.set('browser_state', self.browser.dump_state())
317            self.storage.save()
318
319    def deinit(self):
320        """
321        This abstract method is called when the backend is unloaded.
322        """
323        if self._browser is None:
324            return
325
326        try:
327            self.dump_state()
328        finally:
329            if hasattr(self.browser, 'deinit'):
330                self.browser.deinit()
331
332    _browser = None
333
334    @property
335    def browser(self):
336        """
337        Attribute 'browser'. The browser is created at the first call
338        of this attribute, to avoid useless pages access.
339
340        Note that the :func:`create_default_browser` method is called to create it.
341        """
342        if self._browser is None:
343            self._browser = self.create_default_browser()
344        return self._browser
345
346    def create_default_browser(self):
347        """
348        Method to overload to build the default browser in
349        attribute 'browser'.
350        """
351        return self.create_browser()
352
353    def create_browser(self, *args, **kwargs):
354        """
355        Build a browser from the BROWSER class attribute and the
356        given arguments.
357
358        :param klass: optional parameter to give another browser class to instanciate
359        :type klass: :class:`weboob.browser.browsers.Browser`
360        """
361
362        klass = kwargs.pop('klass', self.BROWSER)
363
364        if not klass:
365            return None
366
367        kwargs['proxy'] = self.get_proxy()
368        if '_proxy_headers' in self._private_config:
369            kwargs['proxy_headers'] = self._private_config['_proxy_headers']
370            if isinstance(kwargs['proxy_headers'], basestring):
371                kwargs['proxy_headers'] = json.loads(kwargs['proxy_headers'])
372
373        kwargs['logger'] = self.logger
374
375        if self.logger.settings['responses_dirname']:
376            kwargs.setdefault('responses_dirname', os.path.join(self.logger.settings['responses_dirname'],
377                                                                self._private_config.get('_debug_dir', self.name)))
378        elif os.path.isabs(self._private_config.get('_debug_dir', '')):
379            kwargs.setdefault('responses_dirname', self._private_config['_debug_dir'])
380        if self._private_config.get('_highlight_el', ''):
381            kwargs.setdefault('highlight_el', bool(int(self._private_config['_highlight_el'])))
382
383        browser = klass(*args, **kwargs)
384
385        if hasattr(browser, 'load_state'):
386            browser.load_state(self.storage.get('browser_state', default={}))
387
388        return browser
389
390    def get_proxy(self):
391        # Get proxies from environment variables
392        proxies = getproxies()
393        # Override them with backend-specific config
394        if '_proxy' in self._private_config:
395            proxies['http'] = self._private_config['_proxy']
396        if '_proxy_ssl' in self._private_config:
397            proxies['https'] = self._private_config['_proxy_ssl']
398        # Remove empty values
399        for key in list(proxies.keys()):
400            if not proxies[key]:
401                del proxies[key]
402        return proxies
403
404    @classmethod
405    def iter_caps(klass):
406        """
407        Iter capabilities implemented by this backend.
408
409        :rtype: iter[:class:`weboob.capabilities.base.Capability`]
410        """
411        for base in klass.mro():
412            if issubclass(base, Capability) and base != Capability and base != klass and not issubclass(base, Module):
413                yield base
414
415    def has_caps(self, *caps):
416        """
417        Check if this backend implements at least one of these capabilities.
418        """
419        for c in caps:
420            if (isinstance(c, basestring) and c in [cap.__name__ for cap in self.iter_caps()]) or \
421               isinstance(self, c):
422                return True
423        return False
424
425    def fillobj(self, obj, fields=None):
426        """
427        Fill an object with the wanted fields.
428
429        :param fields: what fields to fill; if None, all fields are filled
430        :type fields: :class:`list`
431        """
432        if obj is None:
433            return obj
434
435        def not_loaded_or_incomplete(v):
436            return (v is NotLoaded or isinstance(v, BaseObject) and not v.__iscomplete__())
437
438        def not_loaded(v):
439            return v is NotLoaded
440
441        def filter_missing_fields(obj, fields, check_cb):
442            missing_fields = []
443            if fields is None:
444                # Select all fields
445                if isinstance(obj, BaseObject):
446                    fields = [item[0] for item in obj.iter_fields()]
447                else:
448                    fields = [item[0] for item in iter_fields(obj)]
449
450            for field in fields:
451                if not hasattr(obj, field):
452                    raise FieldNotFound(obj, field)
453                value = getattr(obj, field)
454
455                missing = False
456                if hasattr(value, '__iter__'):
457                    for v in (value.values() if isinstance(value, dict) else value):
458                        if check_cb(v):
459                            missing = True
460                            break
461                elif check_cb(value):
462                    missing = True
463
464                if missing:
465                    missing_fields.append(field)
466
467            return missing_fields
468
469        if isinstance(fields, basestring):
470            fields = (fields,)
471
472        missing_fields = filter_missing_fields(obj, fields, not_loaded_or_incomplete)
473
474        if not missing_fields:
475            return obj
476
477        for key, value in self.OBJECTS.items():
478            if isinstance(obj, key):
479                self.logger.debug(u'Fill %r with fields: %s' % (obj, missing_fields))
480                obj = value(self, obj, missing_fields) or obj
481                break
482
483        missing_fields = filter_missing_fields(obj, fields, not_loaded)
484
485        # Object is not supported by backend. Do not notice it to avoid flooding user.
486        # That's not so bad.
487        for field in missing_fields:
488            setattr(obj, field, NotAvailable)
489
490        return obj
491
492
493class AbstractModuleMissingParentError(Exception):
494    pass
495
496
497class AbstractModule(Module):
498    """ Abstract module allow inheritance between modules.
499
500    Sometimes, several websites are based on the same website engine. This module
501    allow to simplify code with a fake inheritance: weboob will install (if needed) and
502    load a PARENT module and build our AbstractModule on top of this class.
503
504    PARENT is a mandatory attribute of any AbstractModule.
505
506    By default an AbstractModule inherits its parent backends CONFIG.
507    To add backend values, use ADDITIONAL_CONFIG.
508    To remove backend values, you must override CONFIG definition.
509
510    Note that you must pass a valid weboob instance as first argument of the constructor.
511    """
512    PARENT = None
513
514    ADDITIONAL_CONFIG = BackendConfig()
515    """Optional additional Values for backends, appended to parent CONFIG
516
517    Values must be weboob.tools.value.Value objects.
518    """
519
520    @classmethod
521    def _resolve_abstract(cls, weboob, name):
522        """ Replace AbstractModule parent with the real base class """
523        if cls.PARENT is None:
524            raise AbstractModuleMissingParentError("PARENT is not defined for module %s" % cls.__name__)
525
526        try:
527            parent = weboob.load_or_install_module(cls.PARENT).klass
528        except ModuleInstallError as err:
529            raise ModuleInstallError('The module %s depends on %s module but %s\'s installation failed with: %s' % (name, cls.PARENT, cls.PARENT, err))
530
531        # Parent may have defined an ADDITIONAL_CONFIG
532        if getattr(parent, 'ADDITIONAL_CONFIG', None):
533            cls.ADDITIONAL_CONFIG = BackendConfig(*(list(parent.ADDITIONAL_CONFIG.values()) + list(cls.ADDITIONAL_CONFIG.values())))
534
535        # Parent may be an AbstractModule as well
536        if hasattr(parent, '_resolve_abstract'):
537            parent._resolve_abstract(weboob, name)
538
539        parent_caps = parent.iter_caps()
540        cls.__bases__ = tuple([parent] + [cap for cap in cls.iter_caps() if cap not in parent_caps])
541        return parent
542
543    def __new__(cls, weboob, name, config=None, storage=None, logger=None, nofail=False):
544        parent = cls._resolve_abstract(weboob=weboob, name=name)
545
546        # fake backend config inheritance, override existing Values
547        # do not use CONFIG to allow the children to overwrite completely the parent CONFIG.
548        if getattr(cls, 'ADDITIONAL_CONFIG', None):
549            cls.CONFIG = BackendConfig(*(list(parent.CONFIG.values()) + list(cls.ADDITIONAL_CONFIG.values())))
550
551        return Module.__new__(cls, weboob, name, config, storage, logger, nofail)
552