Source code for woob.tools.backend

# Copyright(C) 2010-2023 Romain Bignon
#
# This file is part of woob.
#
# woob is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# woob is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with woob. If not, see <http://www.gnu.org/licenses/>.

from __future__ import annotations

import importlib
import logging
import os
import warnings
from collections.abc import Iterator
from copy import copy
from threading import RLock
from typing import TYPE_CHECKING, Any, ClassVar
from urllib.request import getproxies

from packaging.version import Version

from woob import __version__
from woob.capabilities.base import BaseObject, Capability, FieldNotFound, NotAvailable, NotLoaded
from woob.tools.json import json
from woob.tools.log import getLogger
from woob.tools.misc import iter_fields
from woob.tools.storage import IStorage
from woob.tools.value import ValueBool, ValuesDict


if TYPE_CHECKING:
    from woob.browser import Browser
    from woob.core import WoobBase


__all__ = ["BackendStorage", "BackendConfig", "Module"]


[docs]class BackendStorage: """ This is an abstract layer to store data in storages (:mod:`woob.tools.storage`) easily. It is instancied automatically in constructor of :class:`Module`, in the :attr:`Module.storage` attribute. :param name: name of backend :type name: str :param storage: storage object :type storage: :class:`woob.tools.storage.IStorage` """ def __init__(self, name: str, storage: IStorage | None): self.name = name self.storage = storage
[docs] def set(self, *args): """ Set value in the storage. Example: >>> from woob.tools.storage import StandardStorage >>> backend = BackendStorage('blah', StandardStorage('/tmp/cfg')) >>> backend.storage.set('config', 'nb_of_threads', 10) # doctest: +SKIP >>> :param args: the path where to store value """ if self.storage: self.storage.set("backends", self.name, *args)
[docs] def delete(self, *args): """ Delete a value from the storage. :param args: path to delete. """ if self.storage: self.storage.delete("backends", self.name, *args)
[docs] def get(self, *args, **kwargs) -> Any: """ Get a value or a dict of values in storage. Example: >>> from woob.tools.storage import StandardStorage >>> backend = BackendStorage('blah', StandardStorage('/tmp/cfg')) >>> backend.storage.get('config', 'nb_of_threads') # doctest: +SKIP 10 >>> backend.storage.get('config', 'unexistant', 'path', default='lol') # doctest: +SKIP 'lol' >>> backend.storage.get('config') # doctest: +SKIP {'nb_of_threads': 10, 'other_things': 'blah'} :param args: path to get :param default: if specified, default value when path is not found """ if self.storage: return self.storage.get("backends", self.name, *args, **kwargs) return kwargs.get("default", None)
[docs] def load(self, default: dict): """ Load storage. It is made automatically when your backend is created, and use the ``STORAGE`` class attribute as default. :param default: this is the default tree if storage is empty :type default: :class:`dict` """ if self.storage: self.storage.load("backends", self.name, default)
[docs] def save(self): """ Save storage. """ if self.storage: self.storage.save("backends", self.name)
[docs]class BackendConfig(ValuesDict): """ Configuration of a backend. This class is firstly instanced as a :class:`woob.tools.value.ValuesDict`, containing some :class:`woob.tools.value.Value` (and derivated) objects. Then, using the :func:`load` method will load configuration from file and create a copy of the :class:`BackendConfig` object with the loaded values. """ modname: str instname: str woob: WoobBase
[docs] def load(self, woob: WoobBase, modname: str, instname: str, config: dict, nofail: bool = False) -> BackendConfig: """ Load configuration from dict to create an instance. :param woob: woob object :type woob: :class:`woob.core.woob.WoobBase` :param modname: name of the module :type modname: :class:`str` :param instname: name of this backend :type instname: :class:`str` :param params: parameters to load :type params: :class:`dict` :param nofail: if true, this call can't fail :type nofail: :class:`bool` :rtype: :class:`BackendConfig` """ cfg = self.__class__() cfg.modname = modname cfg.instname = instname cfg.woob = woob for name, field in self.items(): value = config.get(name, None) if value is None: if not nofail and field.required: raise Module.ConfigError( f"Backend({cfg.instname}): Configuration error: Missing parameter {name} ({field.description})", bad_fields=[name], ) value = field.default field = copy(field) try: field.load(cfg.instname, value, cfg.woob.requests) except ValueError as v: if not nofail: raise Module.ConfigError( f'Backend({cfg.instname}): Configuration error for field "{name}": {v}', bad_fields=[name] ) cfg[name] = field return cfg
[docs] def dump(self) -> dict: """ Dump config in a dictionary. :rtype: :class:`dict` """ settings = {} for name, value in self.items(): if not value.transient: settings[name] = value.dump() return settings
[docs] def save(self, edit: bool = True, params: dict | None = None): """ Save backend config. :param edit: if true, it changes config of an existing backend :type edit: :class:`bool` :param params: if supplied, params to merge with the ones of the current object :type params: :class:`dict` """ assert self.modname is not None assert self.instname is not None assert self.woob is not None dump = self.dump() if params is not None: dump.update(params) if edit: self.woob.backends_config.edit_backend(self.instname, dump) else: self.woob.backends_config.add_backend(self.instname, self.modname, dump)
[docs]class Module: """ Base class for modules. You may derivate it, and also all capabilities you want to implement. :param woob: woob instance :type woob: :class:`woob.core.woob.Woob` :param name: name of backend :type name: :class:`str` :param config: configuration of backend (optional) :type config: :class:`dict` :param storage: storage object (optional) :type storage: :class:`woob.tools.storage.IStorage` :param logger: parent logger (optional) :type logger: :class:`logging.Logger` """ NAME: ClassVar[str] """Name of the maintainer of this module.""" MAINTAINER: ClassVar[str] = "<unspecified>" """Name of the maintainer.""" EMAIL: ClassVar[str] = "<unspecified>" """Email address of the maintainer.""" DESCRIPTION: ClassVar[str] = "<unspecified>" """Description""" LICENSE: ClassVar[str] = "<unspecified>" """License of the module""" CONFIG: ClassVar[BackendConfig] = BackendConfig() """Configuration required for backends. Values must be :class:`woob.tools.value.Value` objects. """ STORAGE: ClassVar[dict] = {} """Storage""" BROWSER: Browser | None = None """Browser class""" ICON: ClassVar[str | None] = None """URL to an optional icon. If you want to create your own icon, create a 'favicon.png' icon in the module's directory, and keep the ICON value to None. """ OBJECTS: ClassVar[dict] = {} """Supported objects to fill The key is the class and the value the method to call to fill Method prototype: method(object, fields) When the method is called, fields are only the one which are NOT yet filled. """ DEPENDENCIES: ClassVar[tuple[str]] = () """Tuple of module names on which this module depends."""
[docs] class ConfigError(Exception): """ Raised when the config can't be loaded. """ def __init__(self, message, bad_fields=None): """ :type message: str :param message: message of the exception :type bad_fields: list[str] :param bad_fields: names of the config fields which are incorrect """ super().__init__(message) self.bad_fields = bad_fields or ()
def __enter__(self): self.lock.acquire() def __exit__(self, t, v, tb): self.lock.release() def __repr__(self): return f"<Backend {self.name}>" def __new__(cls, *args, **kwargs): """Accept any arguments, necessary for AbstractModule __new__ override. AbstractModule, in its overridden __new__, removes itself from class hierarchy so its __new__ is called only once. In python 3, default (object) __new__ is then used for next instantiations but it's a slot/"fixed" version supporting only one argument (type to instanciate). """ return object.__new__(cls) @property def VERSION(self): warnings.warn( "Attribute Module.VERSION will be removed in woob 4, do not use it.", DeprecationWarning, stacklevel=3 ) return Version(__version__).base_version def __init__( self, woob: WoobBase, name: str, config: dict | None = None, storage: IStorage | None = None, logger: logging.Logger | None = None, nofail: bool = False, ): if hasattr(self.__class__, "VERSION") and not isinstance(self.__class__.VERSION, property): warnings.warn( f"Class attribute {self.__class__.__name__}.VERSION is now " "unused and deprecated, you can remove it. " "If you do so, do not forget to increase the woob version to at " "least 3.4 in requirements.txt.", DeprecationWarning, ) self.logger = getLogger(name, parent=logger) self.woob = woob self.name = name self.lock = RLock() if config is None: config = {} # Private fields (which start with '_') self._private_config = {key: value for key, value in config.items() if key.startswith("_")} # Load configuration of backend. self.config = self.CONFIG.load(woob, self.NAME, self.name, config, nofail) self.storage = BackendStorage(self.name, storage) self.storage.load(self.STORAGE)
[docs] def dump_state(self): """ Dump module state into storage. """ if hasattr(self.browser, "dump_state"): self.storage.set("browser_state", self.browser.dump_state()) self.storage.save()
[docs] def deinit(self): """ This abstract method is called when the backend is unloaded. """ if self._browser is None: return try: self.dump_state() finally: if hasattr(self.browser, "deinit"): self.browser.deinit()
@property def weboob(self): """ .. deprecated:: 3.4 Don't use this attribute, but :attr:`woob` instead. """ warnings.warn("Use Module.woob instead.", DeprecationWarning, stacklevel=2) return self.woob _browser = None @property def browser(self) -> Browser: """ Attribute 'browser'. The browser is created at the first call of this attribute, to avoid useless pages access. Note that the :func:`create_default_browser` method is called to create it. """ if self._browser is None: self._browser = self.create_default_browser() return self._browser
[docs] def create_default_browser(self) -> Browser | None: """ Method to overload to build the default browser in attribute 'browser'. """ return self.create_browser()
[docs] def create_browser(self, *args, **kwargs) -> Browser | None: """ Build a browser from the BROWSER class attribute and the given arguments. :param klass: optional parameter to give another browser class to instanciate :type klass: :class:`woob.browser.browsers.Browser` :param load_state: Whether to load the browser state if it supports the feature. :type load_state: bool """ klass = kwargs.pop("klass", self.BROWSER) if not klass: return None should_load_state = bool(kwargs.pop("load_state", True)) kwargs["proxy"] = self.get_proxy() if "_proxy_headers" in self._private_config: kwargs["proxy_headers"] = self._private_config["_proxy_headers"] if isinstance(kwargs["proxy_headers"], str): kwargs["proxy_headers"] = json.loads(kwargs["proxy_headers"]) if "_ssl_verify" in self._private_config: # value can be either a boolean or a string (path) value = ValueBool() try: value.set(self._private_config["_ssl_verify"]) except ValueError: kwargs.setdefault("verify", self._private_config["_ssl_verify"]) else: kwargs.setdefault("verify", value.get()) kwargs["logger"] = self.logger if self.logger.settings["responses_dirname"]: kwargs.setdefault( "responses_dirname", os.path.join( self.logger.settings["responses_dirname"], self._private_config.get("_debug_dir", self.name) ), ) elif os.path.isabs(self._private_config.get("_debug_dir", "")): kwargs.setdefault("responses_dirname", self._private_config["_debug_dir"]) if "_highlight_el" in self._private_config: value = ValueBool() try: value.set(self._private_config["_highlight_el"]) except ValueError as e: raise Module.ConfigError( f"Backend({self.name}): Configuration error: _highlight_el must be a boolean" ) from e kwargs.setdefault("highlight_el", value.get()) browser = klass(*args, **kwargs) if should_load_state and hasattr(browser, "load_state"): browser.load_state(self.storage.get("browser_state", default={})) return browser
[docs] def get_proxy(self) -> dict[str, str]: """ Get proxy to use. It will read in environment variables, then in backend config. Proxy keys in backend config are: * ``_proxy`` for HTTP requests * ``_proxy_ssl`` for HTTPS requests """ # Get proxies from environment variables proxies = getproxies() # Override them with backend-specific config if "_proxy" in self._private_config: proxies["http"] = self._private_config["_proxy"] if "_proxy_ssl" in self._private_config: proxies["https"] = self._private_config["_proxy_ssl"] # Remove empty values for key in list(proxies.keys()): if not proxies[key]: del proxies[key] return proxies
[docs] @classmethod def iter_caps(cls) -> Iterator[type[Capability]]: """ Iter capabilities implemented by this backend. :rtype: iter[:class:`woob.capabilities.base.Capability`] """ for base in cls.mro(): if issubclass(base, Capability) and base != Capability and base != cls and not issubclass(base, Module): yield base
[docs] def has_caps(self, *caps) -> bool: """ Check if this backend implements at least one of these capabilities. `caps` should be list of :class:`Capability` objects (e.g. :class:`CapBank`) or capability names (e.g. 'bank'). """ available_cap_names = [cap.__name__ for cap in self.iter_caps()] return any((isinstance(c, str) and c in available_cap_names) or isinstance(self, c) for c in caps)
[docs] def fillobj(self, obj: BaseObject, fields: list[str] | None = None): """ Fill an object with the wanted fields. :param fields: what fields to fill; if None, all fields are filled :type fields: :class:`list` """ if obj is None: return obj def not_loaded_or_incomplete(v): return v is NotLoaded or isinstance(v, BaseObject) and not v.__iscomplete__() def not_loaded(v): return v is NotLoaded def filter_missing_fields(obj, fields, check_cb): missing_fields = [] if fields is None: # Select all fields if isinstance(obj, BaseObject): fields = [item[0] for item in obj.iter_fields()] else: fields = [item[0] for item in iter_fields(obj)] for field in fields: if not hasattr(obj, field): raise FieldNotFound(obj, field) value = getattr(obj, field) missing = False if hasattr(value, "__iter__"): for v in value.values() if isinstance(value, dict) else value: if check_cb(v): missing = True break elif check_cb(value): missing = True if missing: missing_fields.append(field) return missing_fields if isinstance(fields, str): fields = (fields,) missing_fields = filter_missing_fields(obj, fields, not_loaded_or_incomplete) if not missing_fields: return obj for key, value in self.OBJECTS.items(): if isinstance(obj, key): self.logger.debug("Fill %r with fields: %s", obj, missing_fields) obj = value(self, obj, missing_fields) or obj break missing_fields = filter_missing_fields(obj, fields, not_loaded) # Object is not supported by backend. Do not notice it to avoid flooding user. # That's not so bad. for field in missing_fields: setattr(obj, field, NotAvailable) return obj
class AbstractModuleMissingParentError(Exception): pass class MetaModule(type): # we can remove this class as soon as we get rid of Abstract* def __new__(mcs, name, bases, dct): if name != "AbstractModule" and AbstractModule in bases: warnings.warn( "AbstractModule is deprecated and will be removed in woob 4.0. " 'Use standard "from woob_modules.other_module import Module" instead.', DeprecationWarning, stacklevel=2, ) module = importlib.import_module("woob_modules.%s" % dct["PARENT"]) symbols = [getattr(module, attr) for attr in dir(module) if not attr.startswith("__")] klass = next( symbol for symbol in symbols if isinstance(symbol, type) and issubclass(symbol, Module) and symbol != Module ) bases = tuple(klass if isinstance(base, mcs) else base for base in bases) additional_config = dct.pop("ADDITIONAL_CONFIG", None) if additional_config: dct["CONFIG"] = BackendConfig(*(list(klass.CONFIG.values()) + list(additional_config.values()))) return super().__new__(mcs, name, bases, dct) class AbstractModule(metaclass=MetaModule): """ .. deprecated:: 3.4 Don't use this class, import woob_modules.other_module.etc instead """