jenkins-bot submitted this change.

View Change


Approvals: Xqt: Looks good to me, approved jenkins-bot: Verified
[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(-)

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."""


To view, visit change 860980. To unsubscribe, or for help writing mail filters, visit settings.

Gerrit-Project: pywikibot/core
Gerrit-Branch: master
Gerrit-Change-Id: Ic6d5bd9a5824b0400fbf4f74fbbcc324d0684ca6
Gerrit-Change-Number: 860980
Gerrit-PatchSet: 7
Gerrit-Owner: Xqt <info@gno.de>
Gerrit-Reviewer: Xqt <info@gno.de>
Gerrit-Reviewer: jenkins-bot
Gerrit-CC: Mpaa <mpaa.wiki@gmail.com>
Gerrit-MessageType: merged