jenkins-bot has submitted this change. ( https://gerrit.wikimedia.org/r/c/pywikibot/core/+/860980 )
Change subject: [IMPR] Move data.api._login.LoginManager to login.ClientLoginManager ......................................................................
[IMPR] Move data.api._login.LoginManager to login.ClientLoginManager
Keep LoginManagers in only one place. Therefore Move data.api._login.LoginManager to login file as ClientLoginManager where also OauthLoginManager resides. This might be important to implement OAuth 2 or other login related issues like interactive login.
Change-Id: Ic6d5bd9a5824b0400fbf4f74fbbcc324d0684ca6 --- M pywikibot/login.py M pywikibot/data/api/__init__.py M tests/utils.py M pywikibot/CONTENT.rst D pywikibot/data/api/_login.py M pywikibot/site/_apisite.py M tests/api_tests.py 7 files changed, 215 insertions(+), 210 deletions(-)
Approvals: Xqt: Looks good to me, approved jenkins-bot: Verified
diff --git a/pywikibot/CONTENT.rst b/pywikibot/CONTENT.rst index 52ba21a..337b855 100644 --- a/pywikibot/CONTENT.rst +++ b/pywikibot/CONTENT.rst @@ -95,8 +95,6 @@ | +----------------+-------------------------------------+ | | _generators.py | API/Query generators | | +----------------+-------------------------------------+ - | | _login.py | API login interface | - | +----------------+-------------------------------------+ | | _optionset.py | Boolean API option | | +----------------+-------------------------------------+ | | _paraminfo.py | API information data object | diff --git a/pywikibot/data/api/__init__.py b/pywikibot/data/api/__init__.py index 6445c8c..c7e6db9 100644 --- a/pywikibot/data/api/__init__.py +++ b/pywikibot/data/api/__init__.py @@ -19,11 +19,11 @@ QueryGenerator, update_page, ) -from pywikibot.data.api._login import LoginManager from pywikibot.data.api._paraminfo import ParamInfo from pywikibot.data.api._optionset import OptionSet from pywikibot.data.api._requests import CachedRequest, Request, encode_url from pywikibot.family import SubdomainFamily +from pywikibot.tools import ModuleDeprecationWrapper
__all__ = ( 'APIGeneratorBase', @@ -31,7 +31,6 @@ 'CachedRequest', 'ListGenerator', 'LogEntryListGenerator', - 'LoginManager', 'OptionSet', 'PageGenerator', 'ParamInfo', @@ -95,3 +94,9 @@
MIMEMultipart = CTEBinaryMIMEMultipart + +wrapper = ModuleDeprecationWrapper(__name__) +wrapper.add_deprecated_attr( + 'LoginManager', + replacement_name='pywikibot.login.ClientLoginManager', + since='8.0.0') diff --git a/pywikibot/data/api/_login.py b/pywikibot/data/api/_login.py deleted file mode 100644 index 09a318d..0000000 --- a/pywikibot/data/api/_login.py +++ /dev/null @@ -1,174 +0,0 @@ -"""API login Interface.""" -# -# (C) Pywikibot team, 2008-2022 -# -# Distributed under the terms of the MIT license. -# -import datetime -import re -from typing import Optional - -import pywikibot -from pywikibot import login -from pywikibot.backports import Dict -from pywikibot.login import LoginStatus -from pywikibot.tools import deprecated - -__all__ = ['LoginManager'] - - -class LoginManager(login.LoginManager): - - """Supply login_to_site method to use API interface. - - .. versionchanged:: 8.0 - 2FA login was enabled. - """ - - # API login parameters mapping - mapping = { - 'user': ('lgname', 'username'), - 'password': ('lgpassword', 'password'), - 'ldap': ('lgdomain', 'domain'), - 'token': ('lgtoken', 'logintoken'), - 'result': ('result', 'status'), - 'success': ('Success', 'PASS'), - 'fail': ('Failed', 'FAIL'), - 'reason': ('reason', 'message') - } - - def keyword(self, key): - """Get API keyword from mapping.""" - return self.mapping[key][self.action != 'login'] - - def _login_parameters(self, *, botpassword: bool = False - ) -> Dict[str, str]: - """Return login parameters.""" - if botpassword: - self.action = 'login' - else: - token = self.site.tokens['login'] - self.action = 'clientlogin' - - # prepare default login parameters - parameters = {'action': self.action, - self.keyword('user'): self.login_name, - self.keyword('password'): self.password} - - if self.action == 'clientlogin': - # clientlogin requires non-empty loginreturnurl - parameters['loginreturnurl'] = 'https://example.com' - parameters['rememberMe'] = '1' - parameters['logintoken'] = token - - if self.site.family.ldapDomain: - parameters[self.keyword('ldap')] = self.site.family.ldapDomain - - return parameters - - def login_to_site(self) -> None: - """Login to the site. - - Note, this doesn't do anything with cookies. The http module - takes care of all the cookie stuff. Throws exception on failure. - - .. versionchanged:: 8.0 - 2FA login was enabled. - """ - if hasattr(self, '_waituntil') \ - and datetime.datetime.now() < self._waituntil: - diff = self._waituntil - datetime.datetime.now() - pywikibot.warning( - 'Too many tries, waiting {} seconds before retrying.' - .format(diff.seconds)) - pywikibot.sleep(diff.seconds) - - self.site._loginstatus = LoginStatus.IN_PROGRESS - - # Bot passwords username contains @, - # otherwise @ is not allowed in usernames. - # @ in bot password is deprecated, - # but we don't want to break bots using it. - parameters = self._login_parameters( - botpassword='@' in self.login_name or '@' in self.password) - - # base login request - login_request = self.site._request(use_get=False, - parameters=parameters) - while True: - # try to login - try: - login_result = login_request.submit() - except pywikibot.exceptions.APIError as e: # pragma: no cover - login_result = {'error': e.__dict__} - - # clientlogin response can be clientlogin or error - if self.action in login_result: - response = login_result[self.action] - result_key = self.keyword('result') - elif 'error' in login_result: - response = login_result['error'] - result_key = 'code' - else: - raise RuntimeError('Unexpected API login response key.') - - status = response[result_key] - fail_reason = response.get(self.keyword('reason'), '') - if status == self.keyword('success'): - return - - if status in ('NeedToken', 'WrongToken', 'badtoken'): - # if incorrect login token was used, - # force relogin and generate fresh one - pywikibot.error('Received incorrect login token. ' - 'Forcing re-login.') - # invalidate superior wiki cookies (T224712) - pywikibot.data.api._invalidate_superior_cookies( - self.site.family) - self.site.tokens.clear() - login_request[ - self.keyword('token')] = self.site.tokens['login'] - continue - - if status == 'UI': # pragma: no cover - oathtoken = pywikibot.input(response['message'], password=True) - login_request['OATHToken'] = oathtoken - login_request['logincontinue'] = True - del login_request['username'] - del login_request['password'] - del login_request['rememberMe'] - continue - - # messagecode was introduced with 1.29.0-wmf.14 - # but older wikis are still supported - login_throttled = response.get('messagecode') == 'login-throttled' - - if (status == 'Throttled' or status == self.keyword('fail') - and (login_throttled or 'wait' in fail_reason)): - wait = response.get('wait') - if wait: - delta = datetime.timedelta(seconds=int(wait)) - else: - match = re.search(r'(\d+) (seconds|minutes)', fail_reason) - if match: - delta = datetime.timedelta(**{match[2]: int(match[1])}) - else: - delta = datetime.timedelta() - self._waituntil = datetime.datetime.now() + delta - - break - - if 'error' in login_result: - raise pywikibot.exceptions.APIError(**response) - - raise pywikibot.exceptions.APIError(code=status, info=fail_reason) - - @deprecated("site.tokens['login']", since='8.0.0') - def get_login_token(self) -> Optional[str]: - """Fetch login token for MediaWiki 1.27+. - - .. deprecated:: 8.0 - - :return: login token - """ - return self.site.tokens['login'] diff --git a/pywikibot/login.py b/pywikibot/login.py index 2a13ffe..1b1b661 100644 --- a/pywikibot/login.py +++ b/pywikibot/login.py @@ -5,7 +5,9 @@ # Distributed under the terms of the MIT license. # import codecs +import datetime import os +import re import webbrowser from enum import IntEnum from typing import Any, Optional @@ -16,7 +18,7 @@ from pywikibot.backports import Dict, Tuple from pywikibot.comms import http from pywikibot.exceptions import APIError, NoUsernameError -from pywikibot.tools import file_mode_checker, normalize_username +from pywikibot.tools import deprecated, file_mode_checker, normalize_username
try: @@ -170,7 +172,7 @@
def login_to_site(self) -> None: """Login to the site.""" - # THIS IS OVERRIDDEN IN data/api.py + # This is overridden in ClientLoginManager raise NotImplementedError
def storecookiedata(self) -> None: @@ -178,30 +180,29 @@ http.cookie_jar.save(ignore_discard=True)
def readPassword(self) -> None: - """ - Read passwords from a file. + """Read passwords from a file.
- DO NOT FORGET TO REMOVE READ ACCESS FOR OTHER USERS!!! - Use chmod 600 password-file. + .. warning:: **Do not forget to remove read access for other + users!** Use chmod 600 for password-file.
All lines below should be valid Python tuples in the form - (code, family, username, password), - (family, username, password) or - (username, password) - to set a default password for an username. The last matching entry will - be used, so default usernames should occur above specific usernames. + ``(code, family, username, password)``, + ``(family, username, password)`` or ``(username, password)`` to + set a default password for an username. The last matching entry + will be used, so default usernames should occur above specific + usernames.
- For BotPasswords the password should be given as a BotPassword object. + .. note:: For BotPasswords the password should be given as a + :class:`BotPassword` object.
The file must be either encoded in ASCII or UTF-8.
- Example:: + **Example**::
('my_username', 'my_default_password') ('wikipedia', 'my_wikipedia_user', 'my_wikipedia_pass') ('en', 'wikipedia', 'my_en_wikipedia_user', 'my_en_wikipedia_pass') - ('my_username', BotPassword( - 'my_BotPassword_suffix', 'my_BotPassword_password')) + ('my_username', BotPassword('my_suffix', 'my_password')) """ # Set path to password file relative to the user_config # but fall back on absolute path for backwards compatibility @@ -215,6 +216,7 @@
with codecs.open(password_file, encoding='utf-8') as f: lines = f.readlines() + line_nr = len(lines) + 1 for line in reversed(lines): line_nr -= 1 @@ -232,8 +234,8 @@ continue
if not 2 <= len(entry) <= 4: - warn('The length of tuple in line {} should be 2 to 4 ({} ' - 'given)'.format(line_nr, entry), _PasswordFileWarning) + warn(f'The length of tuple in line {line_nr} should be 2 to 4 ' + f'({entry} given)', _PasswordFileWarning) continue
code, family, username, password = ( @@ -313,6 +315,164 @@ return False
+class ClientLoginManager(LoginManager): + + """Supply login_to_site method to use API interface. + + .. versionchanged:: 8.0 + 2FA login was enabled. LoginManager was moved from :mod:`data.api` + to :mod:`login` module and renamed to *ClientLoginManager*. + """ + + # API login parameters mapping + mapping = { + 'user': ('lgname', 'username'), + 'password': ('lgpassword', 'password'), + 'ldap': ('lgdomain', 'domain'), + 'token': ('lgtoken', 'logintoken'), + 'result': ('result', 'status'), + 'success': ('Success', 'PASS'), + 'fail': ('Failed', 'FAIL'), + 'reason': ('reason', 'message') + } + + def keyword(self, key): + """Get API keyword from mapping.""" + return self.mapping[key][self.action != 'login'] + + def _login_parameters(self, *, botpassword: bool = False + ) -> Dict[str, str]: + """Return login parameters.""" + if botpassword: + self.action = 'login' + else: + token = self.site.tokens['login'] + self.action = 'clientlogin' + + # prepare default login parameters + parameters = {'action': self.action, + self.keyword('user'): self.login_name, + self.keyword('password'): self.password} + + if self.action == 'clientlogin': + # clientlogin requires non-empty loginreturnurl + parameters['loginreturnurl'] = 'https://example.com' + parameters['rememberMe'] = '1' + parameters['logintoken'] = token + + if self.site.family.ldapDomain: + parameters[self.keyword('ldap')] = self.site.family.ldapDomain + + return parameters + + def login_to_site(self) -> None: + """Login to the site. + + Note, this doesn't do anything with cookies. The http module + takes care of all the cookie stuff. Throws exception on failure. + + .. versionchanged:: 8.0 + 2FA login was enabled. + """ + if hasattr(self, '_waituntil') \ + and datetime.datetime.now() < self._waituntil: + diff = self._waituntil - datetime.datetime.now() + pywikibot.warning( + 'Too many tries, waiting {} seconds before retrying.' + .format(diff.seconds)) + pywikibot.sleep(diff.seconds) + + self.site._loginstatus = LoginStatus.IN_PROGRESS + + # Bot passwords username contains @, + # otherwise @ is not allowed in usernames. + # @ in bot password is deprecated, + # but we don't want to break bots using it. + parameters = self._login_parameters( + botpassword='@' in self.login_name or '@' in self.password) + + # base login request + login_request = self.site._request(use_get=False, + parameters=parameters) + while True: + # try to login + try: + login_result = login_request.submit() + except pywikibot.exceptions.APIError as e: # pragma: no cover + login_result = {'error': e.__dict__} + + # clientlogin response can be clientlogin or error + if self.action in login_result: + response = login_result[self.action] + result_key = self.keyword('result') + elif 'error' in login_result: + response = login_result['error'] + result_key = 'code' + else: + raise RuntimeError('Unexpected API login response key.') + + status = response[result_key] + fail_reason = response.get(self.keyword('reason'), '') + if status == self.keyword('success'): + return + + if status in ('NeedToken', 'WrongToken', 'badtoken'): + # if incorrect login token was used, + # force relogin and generate fresh one + pywikibot.error('Received incorrect login token. ' + 'Forcing re-login.') + # invalidate superior wiki cookies (T224712) + pywikibot.data.api._invalidate_superior_cookies( + self.site.family) + self.site.tokens.clear() + login_request[ + self.keyword('token')] = self.site.tokens['login'] + continue + + if status == 'UI': # pragma: no cover + oathtoken = pywikibot.input(response['message'], password=True) + login_request['OATHToken'] = oathtoken + login_request['logincontinue'] = True + del login_request['username'] + del login_request['password'] + del login_request['rememberMe'] + continue + + # messagecode was introduced with 1.29.0-wmf.14 + # but older wikis are still supported + login_throttled = response.get('messagecode') == 'login-throttled' + + if (status == 'Throttled' or status == self.keyword('fail') + and (login_throttled or 'wait' in fail_reason)): + wait = response.get('wait') + if wait: + delta = datetime.timedelta(seconds=int(wait)) + else: + match = re.search(r'(\d+) (seconds|minutes)', fail_reason) + if match: + delta = datetime.timedelta(**{match[2]: int(match[1])}) + else: + delta = datetime.timedelta() + self._waituntil = datetime.datetime.now() + delta + + break + + if 'error' in login_result: + raise pywikibot.exceptions.APIError(**response) + + raise pywikibot.exceptions.APIError(code=status, info=fail_reason) + + @deprecated("site.tokens['login']", since='8.0.0') + def get_login_token(self) -> Optional[str]: + """Fetch login token for MediaWiki 1.27+. + + .. deprecated:: 8.0 + + :return: login token + """ + return self.site.tokens['login'] + + class BotPassword:
"""BotPassword object for storage in password file.""" diff --git a/pywikibot/site/_apisite.py b/pywikibot/site/_apisite.py index 8032203..10f54a3 100644 --- a/pywikibot/site/_apisite.py +++ b/pywikibot/site/_apisite.py @@ -53,7 +53,7 @@ TitleblacklistError, UnknownExtensionError, ) -from pywikibot.login import LoginStatus as _LoginStatus +from pywikibot import login from pywikibot.site._basesite import BaseSite from pywikibot.site._decorators import need_right, need_version from pywikibot.site._extensions import ( @@ -126,7 +126,7 @@ super().__init__(code, fam, user) self._globaluserinfo: Dict[Union[int, str], Any] = {} self._interwikimap = _InterwikiMap(self) - self._loginstatus = _LoginStatus.NOT_ATTEMPTED + self._loginstatus = login.LoginStatus.NOT_ATTEMPTED self._msgcache: Dict[str, str] = {} self._paraminfo = api.ParamInfo(self) self._siteinfo = Siteinfo(self) @@ -354,7 +354,7 @@ # (below) is successful. Instead, log the problem, # to be increased to 'warning' level once majority # of issues are resolved. - if self._loginstatus == _LoginStatus.IN_PROGRESS: + if self._loginstatus == login.LoginStatus.IN_PROGRESS: pywikibot.log( '{!r}.login() called when a previous login was in progress.' .format(self)) @@ -364,18 +364,18 @@ # logged_in() is False if _userinfo exists, which means this # will have no effect for the invocation from api.py if self.logged_in(): - self._loginstatus = _LoginStatus.AS_USER + self._loginstatus = login.LoginStatus.AS_USER return
# check whether a login cookie already exists for this user # or check user identity when OAuth enabled - self._loginstatus = _LoginStatus.IN_PROGRESS + self._loginstatus = login.LoginStatus.IN_PROGRESS if user: self._username = normalize_username(user) try: del self.userinfo # force reload if self.userinfo['name'] == self.user(): - self._loginstatus = _LoginStatus.AS_USER + self._loginstatus = login.LoginStatus.AS_USER return
# May occur if you are not logged in (no API read permissions). @@ -406,14 +406,15 @@
raise NoUsernameError(error_msg)
- login_manager = api.LoginManager(site=self, user=self.username()) + login_manager = login.ClientLoginManager(site=self, + user=self.username()) if login_manager.login(retry=True, autocreate=autocreate): self._username = login_manager.username del self.userinfo # force reloading
# load userinfo if self.userinfo['name'] == self.username(): - self._loginstatus = _LoginStatus.AS_USER + self._loginstatus = login.LoginStatus.AS_USER return
pywikibot.error('{} != {} after {}.login() and successful ' @@ -423,7 +424,7 @@ type(self).__name__, type(login_manager).__name__))
- self._loginstatus = _LoginStatus.NOT_LOGGED_IN # failure + self._loginstatus = login.LoginStatus.NOT_LOGGED_IN # failure
def _relogin(self) -> None: """Force a login sequence without logging out, using the current user. @@ -433,7 +434,7 @@ from the site. """ del self.userinfo - self._loginstatus = _LoginStatus.NOT_LOGGED_IN + self._loginstatus = login.LoginStatus.NOT_LOGGED_IN self.login()
def logout(self) -> None: @@ -452,7 +453,7 @@ req_params = {'action': 'logout', 'token': self.tokens['csrf']} uirequest = self.simple_request(**req_params) uirequest.submit() - self._loginstatus = _LoginStatus.NOT_LOGGED_IN + self._loginstatus = login.LoginStatus.NOT_LOGGED_IN
# Reset tokens and user properties del self.userinfo diff --git a/tests/api_tests.py b/tests/api_tests.py index c721cb3..5f13c04 100755 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -847,12 +847,12 @@ def setUp(self): """Patch the LoginManager to avoid UI interaction.""" super().setUp() - self.orig_login_manager = pywikibot.data.api.LoginManager - pywikibot.data.api.LoginManager = FakeLoginManager + self.orig_login_manager = pywikibot.login.ClientLoginManager + pywikibot.login.ClientLoginManager = FakeLoginManager
def tearDown(self): """Restore the original LoginManager.""" - pywikibot.data.api.LoginManager = self.orig_login_manager + pywikibot.login.ClientLoginManager = self.orig_login_manager super().tearDown()
@patch.object(pywikibot, 'info') diff --git a/tests/utils.py b/tests/utils.py index 397552d..c71658d 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -428,7 +428,7 @@ return self._disambig
-class FakeLoginManager(pywikibot.data.api.LoginManager): +class FakeLoginManager(pywikibot.login.ClientLoginManager):
"""Loads a fake password."""
pywikibot-commits@lists.wikimedia.org