ouiboube.py 18.7 KB
Newer Older
Romain Bignon's avatar
Romain Bignon committed
1 2
# -*- coding: utf-8 -*-

3
# Copyright(C) 2010-2014 Romain Bignon
4
#
5
# This file is part of weboob.
6
#
7
# weboob is free software: you can redistribute it and/or modify
8
# it under the terms of the GNU Lesser General Public License as published by
9 10 11 12
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# weboob is distributed in the hope that it will be useful,
13
# but WITHOUT ANY WARRANTY; without even the implied warranty of
14
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
# GNU Lesser General Public License for more details.
16
#
17
# You should have received a copy of the GNU Lesser General Public License
18
# along with weboob. If not, see <http://www.gnu.org/licenses/>.
Romain Bignon's avatar
Romain Bignon committed
19 20


21
import os
Romain Bignon's avatar
Romain Bignon committed
22

23
from weboob.core.bcall import BackendsCall
24
from weboob.core.modules import ModulesLoader, RepositoryModulesLoader
25
from weboob.core.backendscfg import BackendsConfig
26
from weboob.core.requests import RequestsManager
27
from weboob.core.repositories import Repositories, PrintProgress
28
from weboob.core.scheduler import Scheduler
29
from weboob.tools.backend import Module
30
from weboob.tools.compat import basestring, unicode
31
from weboob.tools.config.iconfig import ConfigError
32
from weboob.tools.log import getLogger
33
from weboob.exceptions import ModuleLoadError
Romain Bignon's avatar
Romain Bignon committed
34

35

Romain Bignon's avatar
Romain Bignon committed
36
__all__ = ['WebNip', 'Weboob']
37 38


39 40 41
class VersionsMismatchError(ConfigError):
    pass

42

Romain Bignon's avatar
Romain Bignon committed
43
class WebNip(object):
44
    """
Romain Bignon's avatar
Romain Bignon committed
45
    Weboob in Non Integrated Programs
46

Romain Bignon's avatar
Romain Bignon committed
47 48 49
    It provides methods to build backends or call methods on all loaded
    backends.

50 51 52 53 54 55
    You should use this class when you want to build an application
    using Weboob as a library, without using the standard modules nor
    the automatic module download and update machanism. When using
    WebNip, you have to explicitely provide module paths and deal
    yourself with backend configuration.

Romain Bignon's avatar
Romain Bignon committed
56 57
    :param modules_path: path to directory containing modules.
    :type modules_path: :class:`basestring`
58 59
    :param storage: provide a storage where backends can save data
    :type storage: :class:`weboob.tools.storage.IStorage`
Romain Bignon's avatar
Romain Bignon committed
60 61
    :param scheduler: what scheduler to use; default is :class:`weboob.core.scheduler.Scheduler`
    :type scheduler: :class:`weboob.core.scheduler.IScheduler`
62
    """
Romain Bignon's avatar
Romain Bignon committed
63
    VERSION = '1.5'
64

Romain Bignon's avatar
Romain Bignon committed
65
    def __init__(self, modules_path=None, storage=None, scheduler=None):
66
        self.logger = getLogger('weboob')
Christophe Benz's avatar
Christophe Benz committed
67
        self.backend_instances = {}
68
        self.requests = RequestsManager()
Romain Bignon's avatar
Romain Bignon committed
69

70
        if modules_path is None:
71
            import pkg_resources
72 73 74 75 76
            # Package weboob_modules is provided by
            # https://git.weboob.org/weboob/modules
            # and should be pip-installed separately.
            # Note that Weboob users should rather install Weboob modules
            # through https://updates.weboob.org/.
77 78 79
            modules_path = pkg_resources.resource_filename('weboob_modules', '')

        if modules_path:
Romain Bignon's avatar
Romain Bignon committed
80 81
            self.modules_loader = ModulesLoader(modules_path, self.VERSION)

Romain Bignon's avatar
Romain Bignon committed
82 83 84
        if scheduler is None:
            scheduler = Scheduler()
        self.scheduler = scheduler
85

Romain Bignon's avatar
Romain Bignon committed
86 87 88 89 90 91
        self.storage = storage

    def __deinit__(self):
        self.deinit()

    def deinit(self):
92 93 94 95
        """
        Call this method when you stop using Weboob, to
        properly unload all correctly.
        """
Romain Bignon's avatar
Romain Bignon committed
96 97
        self.unload_backends()

98
    def build_backend(self, module_name, params=None, storage=None, name=None, nofail=False, logger=None):
99
        """
Romain Bignon's avatar
Romain Bignon committed
100 101 102 103 104 105 106 107 108 109 110 111
        Create a backend.

        It does not load it into the Weboob object, so you are responsible for
        deinitialization and calls.

        :param module_name: name of module
        :param params: parameters to give to backend
        :type params: :class:`dict`
        :param storage: storage to use
        :type storage: :class:`weboob.tools.storage.IStorage`
        :param name: name of backend
        :type name: :class:`basestring`
112
        :rtype: :class:`weboob.tools.backend.Module`
113 114
        :param nofail: if true, this call can't fail
        :type nofail: :class:`bool`
115
        """
Romain Bignon's avatar
Romain Bignon committed
116
        module = self.modules_loader.get_or_load_module(module_name)
117

118
        backend_instance = module.create_instance(self, name or module_name, params or {}, storage, nofail, logger=logger or self.logger)
Romain Bignon's avatar
Romain Bignon committed
119
        return backend_instance
120

121
    class LoadError(Exception):
122 123 124 125 126 127
        """
        Raised when a backend is unabled to load.

        :param backend_name: name of backend we can't load
        :param exception: exception object
        """
128

129
        def __init__(self, backend_name, exception):
130
            super(WebNip.LoadError, self).__init__(unicode(exception))
131 132
            self.backend_name = backend_name

Romain Bignon's avatar
Romain Bignon committed
133
    def load_backend(self, module_name, name, params=None, storage=None):
134
        """
Romain Bignon's avatar
Romain Bignon committed
135
        Load a backend.
136

Romain Bignon's avatar
Romain Bignon committed
137 138 139 140
        :param module_name: name of module to load
        :type module_name: :class:`basestring`:
        :param name: name of instance
        :type name: :class:`basestring`
141 142 143 144
        :param params: parameters to give to backend
        :type params: :class:`dict`
        :param storage: storage to use
        :type storage: :class:`weboob.tools.storage.IStorage`
145
        :rtype: :class:`weboob.tools.backend.Module`
146
        """
Romain Bignon's avatar
Romain Bignon committed
147 148
        if name is None:
            name = module_name
Romain Bignon's avatar
Romain Bignon committed
149

Romain Bignon's avatar
Romain Bignon committed
150 151
        if name in self.backend_instances:
            raise self.LoadError(name, 'A loaded backend already named "%s"' % name)
152

Romain Bignon's avatar
Romain Bignon committed
153 154 155
        backend = self.build_backend(module_name, params, storage, name)
        self.backend_instances[name] = backend
        return backend
156

157
    def unload_backends(self, names=None):
158 159 160 161 162 163
        """
        Unload backends.

        :param names: if specified, only unload that backends
        :type names: :class:`list`
        """
164
        unloaded = {}
165
        if isinstance(names, basestring):
166 167
            names = [names]
        elif names is None:
168
            names = list(self.backend_instances.keys())
169 170 171 172 173

        for name in names:
            backend = self.backend_instances.pop(name)
            with backend:
                backend.deinit()
174 175 176
            unloaded[backend.name] = backend

        return unloaded
177

178 179 180 181 182 183
    def __getitem__(self, name):
        """
        Alias for :func:`WebNip.get_backend`.
        """
        return self.get_backend(name)

184 185 186 187
    def get_backend(self, name, **kwargs):
        """
        Get a backend from its name.

188 189 190 191 192
        :param name: name of backend to get
        :type name: str
        :param default: if specified, get this value when the backend is not found
        :type default: whatever you want
        :raises: :class:`KeyError` if not found.
193 194 195 196 197 198 199 200
        """
        try:
            return self.backend_instances[name]
        except KeyError:
            if 'default' in kwargs:
                return kwargs['default']
            else:
                raise
201

202
    def count_backends(self):
203 204 205
        """
        Get number of loaded backends.
        """
206 207
        return len(self.backend_instances)

208
    def iter_backends(self, caps=None, module=None):
Romain Bignon's avatar
Romain Bignon committed
209 210 211 212 213
        """
        Iter on each backends.

        Note: each backend is locked when it is returned.

214
        :param caps: optional list of capabilities to select backends
215
        :type caps: tuple[:class:`weboob.capabilities.base.Capability`]
216 217
        :param module: optional name of module
        :type module: :class:`basestring`
218
        :rtype: iter[:class:`weboob.tools.backend.Module`]
Romain Bignon's avatar
Romain Bignon committed
219
        """
220
        for _, backend in sorted(self.backend_instances.items()):
221 222
            if (caps is None or backend.has_caps(caps)) and \
               (module is None or backend.NAME == module):
223 224
                with backend:
                    yield backend
Romain Bignon's avatar
Romain Bignon committed
225

226 227 228 229 230
    def __getattr__(self, name):
        def caller(*args, **kwargs):
            return self.do(name, *args, **kwargs)
        return caller

231
    def do(self, function, *args, **kwargs):
232
        r"""
Romain Bignon's avatar
Romain Bignon committed
233 234 235 236
        Do calls on loaded backends with specified arguments, in separated
        threads.

        This function has two modes:
Christophe Benz's avatar
Christophe Benz committed
237

238
        - If *function* is a string, it calls the method with this name on
Romain Bignon's avatar
Romain Bignon committed
239
          each backends with the specified arguments;
240
        - If *function* is a callable, it calls it in a separated thread with
Christophe Benz's avatar
Christophe Benz committed
241 242
          the locked backend instance at first arguments, and \*args and
          \*\*kwargs.
Romain Bignon's avatar
Romain Bignon committed
243

244 245 246 247 248
        :param function: backend's method name, or a callable object
        :type function: :class:`str`
        :param backends: list of backends to iterate on
        :type backends: list[:class:`str`]
        :param caps: iterate on backends which implement this caps
249
        :type caps: list[:class:`weboob.capabilities.base.Capability`]
250
        :rtype: A :class:`weboob.core.bcall.BackendsCall` object (iterable)
Romain Bignon's avatar
Romain Bignon committed
251
        """
252
        backends = list(self.backend_instances.values())
253
        _backends = kwargs.pop('backends', None)
254
        if _backends is not None:
255
            if isinstance(_backends, Module):
256
                backends = [_backends]
257
            elif isinstance(_backends, basestring):
258 259 260
                if len(_backends) > 0:
                    try:
                        backends = [self.backend_instances[_backends]]
261
                    except (ValueError, KeyError):
262
                        backends = []
263
            elif isinstance(_backends, (list, tuple, set)):
264
                backends = []
265
                for backend in _backends:
266
                    if isinstance(backend, basestring):
267 268
                        try:
                            backends.append(self.backend_instances[backend])
269
                        except (ValueError, KeyError):
270 271 272
                            pass
                    else:
                        backends.append(backend)
273
            else:
274
                self.logger.warning(u'The "backends" value isn\'t supported: %r', _backends)
275 276

        if 'caps' in kwargs:
Romain Bignon's avatar
Romain Bignon committed
277 278
            caps = kwargs.pop('caps')
            backends = [backend for backend in backends if backend.has_caps(caps)]
279 280 281 282 283

        # The return value MUST BE the BackendsCall instance. Please never iterate
        # here on this object, because caller might want to use other methods, like
        # wait() on callback_thread().
        # Thanks a lot.
284
        return BackendsCall(backends, function, *args, **kwargs)
285

Romain Bignon's avatar
Romain Bignon committed
286
    def schedule(self, interval, function, *args):
287 288 289 290 291 292 293 294 295 296
        """
        Schedule an event.

        :param interval: delay before calling the function
        :type interval: int
        :param function: function to call
        :type function: callabale
        :param args: arguments to give to function
        :returns: an event identificator
        """
Romain Bignon's avatar
Romain Bignon committed
297 298 299
        return self.scheduler.schedule(interval, function, *args)

    def repeat(self, interval, function, *args):
300 301 302 303 304 305 306 307 308 309
        """
        Repeat a call to a function

        :param interval: interval between two calls
        :type interval: int
        :param function: function to call
        :type function: callable
        :param args: arguments to give to function
        :returns: an event identificator
        """
Romain Bignon's avatar
Romain Bignon committed
310
        return self.scheduler.repeat(interval, function, *args)
Romain Bignon's avatar
Romain Bignon committed
311

Romain Bignon's avatar
Romain Bignon committed
312
    def cancel(self, ev):
313 314 315 316 317
        """
        Cancel an event

        :param ev: the event identificator
        """
Romain Bignon's avatar
Romain Bignon committed
318 319
        return self.scheduler.cancel(ev)

320
    def want_stop(self):
321 322 323
        """
        Plan to stop the scheduler.
        """
324 325
        return self.scheduler.want_stop()

Romain Bignon's avatar
Romain Bignon committed
326
    def loop(self):
327 328 329
        """
        Run the scheduler loop
        """
Romain Bignon's avatar
Romain Bignon committed
330
        return self.scheduler.run()
Romain Bignon's avatar
Romain Bignon committed
331

332 333 334 335
    def load_or_install_module(self, module_name):
        """ Load a backend, but can't install it """
        return self.modules_loader.get_or_load_module(module_name)

Romain Bignon's avatar
Romain Bignon committed
336 337 338 339 340 341 342 343

class Weboob(WebNip):
    """
    The main class of Weboob, used to manage backends, modules repositories and
    call methods on all loaded backends.

    :param workdir: optional parameter to set path of the working directory
    :type workdir: str
344 345
    :param datadir: optional parameter to set path of the data directory
    :type datadir: str
Romain Bignon's avatar
Romain Bignon committed
346 347 348 349 350 351 352 353
    :param backends_filename: name of the *backends* file, where configuration of
                              backends is stored
    :type backends_filename: str
    :param storage: provide a storage where backends can save data
    :type storage: :class:`weboob.tools.storage.IStorage`
    """
    BACKENDS_FILENAME = 'backends'

354
    def __init__(self, workdir=None, datadir=None, backends_filename=None, scheduler=None, storage=None):
355
        super(Weboob, self).__init__(modules_path=False, scheduler=scheduler, storage=storage)
Romain Bignon's avatar
Romain Bignon committed
356 357

        # Create WORKDIR
358 359
        if workdir is None:
            if 'WEBOOB_WORKDIR' in os.environ:
360
                workdir = os.environ['WEBOOB_WORKDIR']
361 362
            else:
                workdir = os.path.join(os.environ.get('XDG_CONFIG_HOME', os.path.join(os.path.expanduser('~'), '.config')), 'weboob')
Romain Bignon's avatar
Romain Bignon committed
363 364 365 366

        self.workdir = os.path.realpath(workdir)
        self._create_dir(workdir)

367 368 369
        # Create DATADIR
        if datadir is None:
            if 'WEBOOB_DATADIR' in os.environ:
370 371 372
                datadir = os.environ['WEBOOB_DATADIR']
            elif 'WEBOOB_WORKDIR' in os.environ:
                datadir = os.environ['WEBOOB_WORKDIR']
373 374 375 376 377 378
            else:
                datadir = os.path.join(os.environ.get('XDG_DATA_HOME', os.path.join(os.path.expanduser('~'), '.local', 'share')), 'weboob')

        _datadir = os.path.realpath(datadir)
        self._create_dir(_datadir)

Romain Bignon's avatar
Romain Bignon committed
379
        # Modules management
380
        self.repositories = Repositories(workdir, _datadir, self.VERSION)
Romain Bignon's avatar
Romain Bignon committed
381 382 383 384 385 386 387 388 389 390 391 392 393
        self.modules_loader = RepositoryModulesLoader(self.repositories)

        # Backend instances config
        if not backends_filename:
            backends_filename = os.environ.get('WEBOOB_BACKENDS', os.path.join(self.workdir, self.BACKENDS_FILENAME))
        elif not backends_filename.startswith('/'):
            backends_filename = os.path.join(self.workdir, backends_filename)
        self.backends_config = BackendsConfig(backends_filename)

    def _create_dir(self, name):
        if not os.path.exists(name):
            os.makedirs(name)
        elif not os.path.isdir(name):
394
            self.logger.error(u'"%s" is not a directory', name)
Romain Bignon's avatar
Romain Bignon committed
395

396
    def update(self, progress=PrintProgress()):
Romain Bignon's avatar
Romain Bignon committed
397 398 399 400 401
        """
        Update modules from repositories.
        """
        self.repositories.update(progress)

402
        modules_to_check = set([module_name for _, module_name, _ in self.backends_config.iter_backends()])
Romain Bignon's avatar
Romain Bignon committed
403 404 405 406 407
        for module_name in modules_to_check:
            minfo = self.repositories.get_module_info(module_name)
            if minfo and not minfo.is_installed():
                self.repositories.install(minfo, progress)

408
    def build_backend(self, module_name, params=None, storage=None, name=None, nofail=False):
Romain Bignon's avatar
Romain Bignon committed
409 410 411 412 413 414 415 416 417 418
        """
        Create a single backend which is not listed in configuration.

        :param module_name: name of module
        :param params: parameters to give to backend
        :type params: :class:`dict`
        :param storage: storage to use
        :type storage: :class:`weboob.tools.storage.IStorage`
        :param name: name of backend
        :type name: :class:`basestring`
419
        :rtype: :class:`weboob.tools.backend.Module`
420 421
        :param nofail: if true, this call can't fail
        :type nofail: :class:`bool`
Romain Bignon's avatar
Romain Bignon committed
422 423 424 425 426 427 428 429
        """
        minfo = self.repositories.get_module_info(module_name)
        if minfo is None:
            raise ModuleLoadError(module_name, 'Module does not exist.')

        if not minfo.is_installed():
            self.repositories.install(minfo)

430
        return super(Weboob, self).build_backend(module_name, params, storage, name, nofail)
Romain Bignon's avatar
Romain Bignon committed
431 432 433 434 435 436

    def load_backends(self, caps=None, names=None, modules=None, exclude=None, storage=None, errors=None):
        """
        Load backends listed in config file.

        :param caps: load backends which implement all of specified caps
437
        :type caps: tuple[:class:`weboob.capabilities.base.Capability`]
438
        :param names: load backends in list
Romain Bignon's avatar
Romain Bignon committed
439 440 441
        :type names: tuple[:class:`str`]
        :param modules: load backends which module is in list
        :type modules: tuple[:class:`str`]
442
        :param exclude: do not load backends in list
Romain Bignon's avatar
Romain Bignon committed
443 444 445 446 447 448
        :type exclude: tuple[:class:`str`]
        :param storage: use this storage if specified
        :type storage: :class:`weboob.tools.storage.IStorage`
        :param errors: if specified, store every errors in this list
        :type errors: list[:class:`LoadError`]
        :returns: loaded backends
449
        :rtype: dict[:class:`str`, :class:`weboob.tools.backend.Module`]
Romain Bignon's avatar
Romain Bignon committed
450 451 452 453 454 455 456 457 458
        """
        loaded = {}
        if storage is None:
            storage = self.storage

        if not self.repositories.check_repositories():
            self.logger.error(u'Repositories are not consistent with the sources.list')
            raise VersionsMismatchError(u'Versions mismatch, please run "weboob-config update"')

459
        for backend_name, module_name, params in self.backends_config.iter_backends():
Romain Bignon's avatar
Romain Bignon committed
460
            if '_enabled' in params and not params['_enabled'].lower() in ('1', 'y', 'true', 'on', 'yes') or \
461
               names is not None and backend_name not in names or \
Romain Bignon's avatar
Romain Bignon committed
462
               modules is not None and module_name not in modules or \
463
               exclude is not None and backend_name in exclude:
Romain Bignon's avatar
Romain Bignon committed
464 465 466 467 468
                continue

            minfo = self.repositories.get_module_info(module_name)
            if minfo is None:
                self.logger.warning(u'Backend "%s" is referenced in %s but was not found. '
469
                                    u'Perhaps a missing repository or a removed module?', module_name, self.backends_config.confpath)
Romain Bignon's avatar
Romain Bignon committed
470 471 472 473 474 475 476 477 478 479 480 481
                continue

            if caps is not None and not minfo.has_caps(caps):
                continue

            if not minfo.is_installed():
                self.repositories.install(minfo)

            module = None
            try:
                module = self.modules_loader.get_or_load_module(module_name)
            except ModuleLoadError as e:
482
                self.logger.error(u'Unable to load module "%s": %s', module_name, e)
Romain Bignon's avatar
Romain Bignon committed
483 484
                continue

485 486 487
            if backend_name in self.backend_instances:
                self.logger.warning(u'Oops, the backend "%s" is already loaded. Unload it before reloading...', backend_name)
                self.unload_backends(backend_name)
Romain Bignon's avatar
Romain Bignon committed
488 489

            try:
490
                backend_instance = module.create_instance(self, backend_name, params, storage)
491
            except Module.ConfigError as e:
Romain Bignon's avatar
Romain Bignon committed
492
                if errors is not None:
493
                    errors.append(self.LoadError(backend_name, e))
Romain Bignon's avatar
Romain Bignon committed
494
            else:
495
                self.backend_instances[backend_name] = loaded[backend_name] = backend_instance
Romain Bignon's avatar
Romain Bignon committed
496
        return loaded
497 498 499 500 501 502 503 504

    def load_or_install_module(self, module_name):
        """ Load a backend, and install it if not done before """
        try:
            return self.modules_loader.get_or_load_module(module_name)
        except ModuleLoadError:
            self.repositories.install(module_name)
            return self.modules_loader.get_or_load_module(module_name)