diff --git a/docs/source/conf.py b/docs/source/conf.py index 6d003e0832fd5d2adec8e199c138f2c2426fdb0a..2bdc7c581a452182855b1db418a3243c65f5954c 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,6 +1,11 @@ import sys import os import time +import re +import inspect + +import sphinx_autodoc_typehints +from sphinx_autodoc_typehints import get_annotation_module, get_annotation_class_name, get_annotation_args, format_annotation parentdir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, parentdir) @@ -30,6 +35,8 @@ 'sorted-toctree', #'sphinx.ext.autosectionlabel', 'sphinx.ext.intersphinx', + 'sphinx_autodoc_typehints', + "sphinxcontrib.jquery", #'sphinxcontrib.autoprogram', ] @@ -99,12 +106,67 @@ # Have a class doc along with its __init__ #autoclass_content = 'monokai' autodoc_member_order = 'bysource' +autodoc_preserve_defaults = True intersphinx_mapping = { 'py': ('https://docs.python.org/3', None), 'requests': ('https://requests.readthedocs.io/en/latest/', None) } +typehints_defaults = 'braces-after' +simplify_optional_unions = False + +# Better display of default values +def new_format_default(app, default, is_annotated=True): + if default is inspect.Parameter.empty: + return None + formatted = repr(default).replace("\\", "\\\\") + + m = re.match("<class '(.*)'>", formatted) + if m: + formatted = f':class:`~{m.group(1)}`' + else: + m = re.match("<(.*) object.*>", formatted) + if m: + formatted = f':class:`~{m.group(1)}()`' + else: + m = re.match('<(.*)>', formatted) + if m: + formatted = f':class:`~{m.group(1)}`' + else: + formatted = f':class:`{formatted}`' + + if is_annotated: + if app.config.typehints_defaults.startswith("braces"): + return f" (default: {formatted})" + else: # other option is comma + return f", default: {formatted}" + else: + if app.config.typehints_defaults == "braces-after": + return f" (default: {formatted})" + else: + return f"default: {formatted}" + +sphinx_autodoc_typehints.format_default = new_format_default + +# Do not display neither Optional nor Union, but only pipes. +def formatter(annotation, config): + try: + module = get_annotation_module(annotation) + class_name = get_annotation_class_name(annotation, module) + args = get_annotation_args(annotation, module, class_name) + except ValueError: + return str(annotation).strip("'") + + + full_name = f"{module}.{class_name}" if module != "builtins" else class_name + if full_name in ("types.UnionType", "typing.Union", "typing.Optional"): + return " | ".join([format_annotation(arg, config) for arg in args]) + + return None + +typehints_formatter = formatter + # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. Major themes that come with diff --git a/docs/source/genapi.py b/docs/source/genapi.py index ba79c8ea09afe7e2795ec91574b431eacb23defd..adf6eb5516c3b88c70e78a2b2d8b65a45af55c45 100755 --- a/docs/source/genapi.py +++ b/docs/source/genapi.py @@ -24,7 +24,7 @@ def genapi(): continue f, ext = f.rsplit('.', 1) - if ext != 'py' or f == '__init__': + if ext != 'py' or f.startswith('__'): continue subs.add(f) diff --git a/requirements-dev.txt b/requirements-dev.txt index 56cbebf278ec7c71e31d975b781c0e5d90ac353d..e9faf56a2d2596c721a14cc5e72046fcf71d311c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -9,6 +9,8 @@ pyflakes==2.5.0 pytest-cov==4.0.0 pytest==7.2.1 sphinx +sphinx-autodoc-typehints +sphinxcontrib-jquery virtualenv xunitparser>=1.3.4 pre-commit diff --git a/woob/browser/browsers.py b/woob/browser/browsers.py index d7a2e732c038ef8cda2bd15a7df1ec539b1d58da..5fc04df4a4c51613561a0ab3cae91cb3a3489043 100644 --- a/woob/browser/browsers.py +++ b/woob/browser/browsers.py @@ -17,6 +17,7 @@ from __future__ import annotations +from abc import abstractmethod from collections import OrderedDict from functools import wraps import importlib @@ -1084,38 +1085,77 @@ def pagination(self, func: Callable, *args, **kwargs): return -def need_login(func): +class NotAuthenticated(Exception): + """ + Exception to indicate the browser is not authenticated. + + Use this exception in :meth:`AuthenticationMixin.check_authentication` if + the browser is not logged, then :func:`require_authentication` may call + :meth:`AuthenticationMixin.authenticate`. + """ + + +class AuthenticationExpired(NotAuthenticated): + """ + Exception to indicate the authentication of the browser is expired and + should be done again. + + Use this exception in :meth:`AuthenticationMixin.check_authentication` if + the browser is not logged, then :func:`require_authentication` may call + :meth:`AuthenticationMixin.authenticate`. """ - Decorator used to require to be logged to access to this function. + + +def require_authentication(func): + """ + Decorator used to require to be authenticated to access to this function. This decorator can be used on any method whose first argument is a - browser (typically a :class:`LoginBrowser`). It checks for the - ``logged`` attribute in the current browser's page: when this - attribute is set to ``True`` (e.g., when the page inherits - :class:`~woob.browser.pages.LoggedPage`), then nothing special happens. - - In all other cases (when the browser isn't on any defined page or - when the page's ``logged`` attribute is ``False``), the - :meth:`LoginBrowser.do_login` method of the browser is called before - calling :`func`. + browser implementing the :class:`AuthenticationMixin` mixin (typically a + :class:`LoginBrowser`). + + It calls :meth:`AuthenticationMixin.check_authentication` which may raise + a :class:`NotAuthenticated` exception if the browser is not logged-in. + + When the browser is not authenticated, it calls the method + :meth:`AuthenticationMixin.authenticate` before calling the decorated + function. + + >>> class MyBrowser(Browser, AuthenticationMixin): + ... logged = False + ... + ... def check_authentication(self): + ... if not self.logged: + ... raise NotAuthenticated() + ... + ... def authenticate(self): + ... self.logged = True + ... + ... @require_authentication + ... def do_something(self): + ... return self.logged + ... + >>> b = MyBrowser() + >>> b.do_something() + True """ @wraps(func) def inner(browser: LoginBrowser, *args, **kwargs): - if ( - ( - not hasattr(browser, 'logged') or - ( - hasattr(browser, 'logged') and - not browser.logged - ) - ) and ( - not hasattr(browser, 'page') or - browser.page is None or - not browser.page.logged - ) - ): - browser.do_login() + try: + if not isinstance(browser, AuthenticationMixin): + warnings.warn( + 'Do not use need_login/need_authentication on browsers not ' + 'implementing LoginBrowser/AuthenticationMixin. Assuming we are ' + 'not authenticated', + DeprecationWarning, + stacklevel=2) + raise NotAuthenticated() + + browser.check_authentication() + except NotAuthenticated: + browser.authenticate() + if browser.logger.settings.get('export_session'): browser.logger.debug('logged in with session: %s', json.dumps(browser.export_session())) return func(browser, *args, **kwargs) @@ -1123,7 +1163,53 @@ def inner(browser: LoginBrowser, *args, **kwargs): return inner -class LoginBrowser(PagesBrowser): +def need_login(func): + warnings.warn( + 'Use `require_authentication` decorator instead of `need_login`.', + DeprecationWarning, + stacklevel=2 + ) + return require_authentication(func) + + +class AuthenticationMixin: + """ + Mixin to handle authentication on a browser. + + When a browser implements this mixin, it can use the + :func:`require_authentication` decorator on methods which requires to be + authenticated. + + There are two mandatory methods to overload (:meth:`check_authentication()` + and :meth:`authenticate`) and one optional method + (:meth:`clear_authentication`). + """ + + @abstractmethod + def check_authentication(self) -> None: + """ + Method to overload called to check if the browser is authenticated or not. + + :raises:NotAuthenticated: the browser is currently not authenticated + :raises:AuthenticationExpired: the authentication has expired + """ + + @abstractmethod + def authenticate(self) -> None: + """ + Method to overload to authenticate. + + This method is called by the :func:`require_authentication` decorator + if the browser is not authenticated. + """ + + def clear_authentication(self) -> None: + """ + Method to overload to clear authentication. + """ + + +class LoginBrowser(PagesBrowser, AuthenticationMixin): """ A browser which supports login. """ @@ -1133,19 +1219,61 @@ def __init__(self, username: str, password: str, *args, **kwargs): self.username = username self.password = password - def do_login(self): + def check_authentication(self) -> None: + """ + Method which checks if the browser is authenticated. + + If not authenticated, it may raises one of the following exception: + + * :class:`NotAuthenticated` + * :class:`AuthenticationExpired` + + The default implementation checks a potential `logged` attribute on a + browser, or a `logged` attribute on the current page if there is one. + """ + if hasattr(self, 'logged') and not self.logged: + raise NotAuthenticated() + + if self.page is not None and not self.page.logged: + raise NotAuthenticated() + + @abstractmethod + def authenticate(self) -> None: + """ + Abstract method to overload to authenticate. + + This method is called by the :func:`require_authentication` decorator + if the browser is not authenticated. + """ + return self.do_login() + + @abstractmethod + def do_login(self) -> None: """ Abstract method to implement to login on website. It is called when a login is needed. + + .. deprecated:: 3.5 + Overload :meth:`authenticate` instead. + """ + + def clear_authentication(self) -> None: """ - raise NotImplementedError() + Clear authentication. - def do_logout(self): + By default, simply clears the cookies. + """ + return self.do_logout() + + def do_logout(self) -> None: """ Logout from website. By default, simply clears the cookies. + + .. deprecated:: 3.5 + Overload :meth:`clear_authentication` instead. """ self.session.cookies.clear() diff --git a/woob/tools/application/console.py b/woob/tools/application/console.py index 40f7cfa92f28787b1c4c839b94e701b2b6e9bedd..14b17f142cc9d0f8ea68b9a8d32daceb804f6cc9 100644 --- a/woob/tools/application/console.py +++ b/woob/tools/application/console.py @@ -115,6 +115,11 @@ class ConsoleApplication(Application): @classproperty def BOLD(self): + """ + .. deprecated:: 3.6 + This attribute is deprecated, use :attr:`woob.tools.application.pretty.BOLD` instead. + That's also better to use :func:`woob.tools.application.pretty.colored`. + """ warnings.warn( 'Use woob.tools.application.pretty.BOLD instead.\n' 'That\'s also better to use woob.tools.application.pretty.colored.', @@ -125,6 +130,11 @@ def BOLD(self): @classproperty def NC(self): + """ + .. deprecated:: 3.6 + This attribute is deprecated, use :attr:`woob.tools.application.pretty.NC` instead. + That's also better to use :func:`woob.tools.application.pretty.colored`. + """ warnings.warn( 'Use woob.tools.application.pretty.NC instead.\n' 'That\'s also better to use woob.tools.application.pretty.colored.', diff --git a/woob/tools/captcha/virtkeyboard.py b/woob/tools/captcha/virtkeyboard.py index 411f5daf8784b9d8527d1fe09cac887e8711a6ff..d1c039f5dae6a5c0c839074301ddf84143686a68 100644 --- a/woob/tools/captcha/virtkeyboard.py +++ b/woob/tools/captcha/virtkeyboard.py @@ -15,8 +15,15 @@ # 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 hashlib import tempfile +from typing import IO, ClassVar, TYPE_CHECKING + +if TYPE_CHECKING: + from woob.browser import Browser + try: from PIL import Image @@ -31,17 +38,19 @@ class VirtKeyboardError(Exception): class VirtKeyboard: """ Handle a virtual keyboard. - - :attribute margin: Margin used by :meth:`get_symbol_coords` to reduce size - of each "key" of the virtual keyboard. This attribute is always - converted to a 4-tuple, and has the same semantic as the CSS - ``margin`` property (top, right, bottom, right), in pixels. - :type margin: int or float or (2|3|4)-tuple """ + margin = None + """ + Margin used by :meth:`get_symbol_coords` to reduce size + of each "key" of the virtual keyboard. This attribute is always + converted to a 4-tuple, and has the same semantic as the CSS + ``margin`` property (top, right, bottom, right), in pixels. + """ codesep = '' - """Output separator between code strings. + """ + Output separator between code strings. See :func:`get_string_code`. """ @@ -209,26 +218,22 @@ class GridVirtKeyboard(VirtKeyboard): Make a virtual keyboard where "keys" are distributed on a grid. Example here: https://www.e-sgbl.com/portalserver/sgbl-web/login - Parameters: - :param symbols: Sequence of symbols, ordered in the grid from left to - right and up to down - :type symbols: iterable - :param cols: Column count of the grid - :type cols: int - :param rows: Row count of the grid - :type rows: int - :param image: File-like object to be used as data source - :type image: file - :param color: Color of the meaningful pixels - :type color: 3-tuple - :param convert: Mode to which convert color of pixels, see - :meth:`Image.Image.convert` for more information - - Attributes: - :attribute symbols: Association table between symbols and md5s - :type symbols: dict + :param symbols: Sequence of symbols, ordered in the grid from left to + right and up to down + :type symbols: iterable + :param cols: Column count of the grid + :type cols: int + :param rows: Row count of the grid + :type rows: int + :param image: File-like object to be used as data source + :type image: file + :param color: Color of the meaningful pixels + :type color: 3-tuple + :param convert: Mode to which convert color of pixels, see + :meth:`Image.Image.convert` for more information """ symbols = {} + """Assocation table between symbols and md5s""" def __init__(self, symbols, cols, rows, image, color, convert=None): self.load_image(image, color, convert) @@ -249,11 +254,9 @@ class SplitKeyboard: """Virtual keyboard for when the chars are in individual images, not a single grid""" char_to_hash = None - """dict mapping password characters to image hashes""" codesep = '' - """Output separator between symbols""" def __init__(self, code_to_filedata): @@ -319,52 +322,59 @@ def __init__(self, matching_symbol, coords, image=None, md5=None): class SimpleVirtualKeyboard: """Handle a virtual keyboard where "keys" are distributed on a simple grid. - Parameters: - :param cols: Column count of the grid - :type cols: int - :param rows: Row count of the grid - :type rows: int - :param image: File-like object to be used as data source - :type image: file - :param convert: Mode to which convert color of pixels, see - :meth:`Image.Image.convert` for more information - :param matching_symbols: symbol that match all case of image grid from left to right and top - to down, European reading way. - :type matching_symbols: iterable - :param matching_symbols_coords: dict mapping matching website symbols to their image coords - (x0, y0, x1, y1) on grid image from left to right and top to - down, European reading way. It's not symbols in the image. - :type matching_symbols_coords: dict[str:4-tuple(int)] - :param browser: Browser of woob session. - Allow to dump tiles files in same directory than session folder - :type browser: obj(Browser) - - Attributes: - :attribute codesep: Output separator between matching symbols - :type codesep: str - :param margin: Useless image pixel to cut. - See :func:`cut_margin`. - :type margin: 4-tuple(int), same as HTML margin: (top, right, bottom, left). - or 2-tuple(int), (top = bottom, right = left), - or int, top = right = bottom = left - :attribute tile_margin: Useless tile pixel to cut. - See :func:`cut_margin`. - :attribute symbols: Association table between image symbols and md5s - :type symbols: dict[str:str] or dict[str:n-tuple(str)] - :attribute convert: Mode to which convert color of pixels, see - :meth:`Image.Image.convert` for more information - :attribute alter: Allow custom main image alteration. Then overwrite :func:`alter_image`. - :type alter: boolean + :param cols: Column count of the grid + :param rows: Row count of the grid + :param file: File-like object to be used as data source + :param convert: Mode to which convert color of pixels, see + :meth:`Image.Image.convert` for more information + :param matching_symbols: symbol that match all case of image grid from left to right and top + to down, European reading way. + :param matching_symbols_coords: dict mapping matching website symbols to their image coords + (x0, y0, x1, y1) on grid image from left to right and top to + down, European reading way. It's not symbols in the image. + :param browser: Browser of woob session. + Allow to dump tiles files in same directory than session folder + """ + + codesep: ClassVar[str] = '' + """Output separator between matching symbols""" + + margin: ClassVar[tuple[int, int, int, int] | tuple[int, int] | int | None] = None + """ + 4-tuple(int), same as HTML margin: (top, right, bottom, left). + or 2-tuple(int), (top = bottom, right = left), + or int, top = right = bottom = left + """ + + tile_margin: ClassVar[tuple[int, int, int, int] | tuple[int, int] | int | None] = None + """ + 4-tuple(int), same as HTML margin: (top, right, bottom, left). + or 2-tuple(int), (top = bottom, right = left), + or int, top = right = bottom = left + """ + + symbols: ClassVar[dict[str, str | tuple[str, ...]]] = None + """ + Association table between image symbols and md5s + """ + + convert: ClassVar[str | None] = None + """ + Mode to which convert color of pixels, see + :meth:`Image.Image.convert` for more information """ - codesep = '' - margin = None - tile_margin = None - symbols = None - convert = None tile_klass = Tile - def __init__(self, file, cols, rows, matching_symbols=None, matching_symbols_coords=None, browser=None): + def __init__( + self, + file: IO, + cols: int, + rows: int, + matching_symbols: list[str] | None = None, + matching_symbols_coords: dict[str, tuple[int, int, int, int]] | None = None, + browser: Browser | None = None + ): self.cols = cols self.rows = rows