jenkins-bot has submitted this change and it was merged.
Change subject: Add support for BotPasswords ......................................................................
Add support for BotPasswords
This is done by introducing a separate format for storing BotPasswords (and associated login name) in a password_file.
Plus add tests for password_file parsing in login_tests.py (Merlijn van Deen)
Bug: T124466 Bug: T143417 Signed-off-by: Merlijn van Deen valhallasw@arctus.nl Change-Id: I4c5bafba17891461dc849165ddf59c0f23723010 --- M pywikibot/data/api.py M pywikibot/login.py M setup.py A tests/login_tests.py M tox.ini 5 files changed, 236 insertions(+), 9 deletions(-)
Approvals: Merlijn van Deen: Looks good to me, approved jenkins-bot: Verified
diff --git a/pywikibot/data/api.py b/pywikibot/data/api.py index c3d95e8..109b382 100644 --- a/pywikibot/data/api.py +++ b/pywikibot/data/api.py @@ -3022,7 +3022,7 @@ login_request = self.site._request( use_get=False, parameters=dict(action='login', - lgname=self.username, + lgname=self.login_name, lgpassword=self.password))
# get token using meta=tokens if supported diff --git a/pywikibot/login.py b/pywikibot/login.py index 20f38f7..300d554 100644 --- a/pywikibot/login.py +++ b/pywikibot/login.py @@ -27,7 +27,10 @@
from pywikibot import config from pywikibot.exceptions import NoUsername -from pywikibot.tools import deprecated_args, normalize_username +from pywikibot.tools import deprecated_args, normalize_username, PY2 + +if not PY2: + unicode = basestring = str
class OAuthImpossible(ImportError): @@ -113,6 +116,7 @@ % {'fam_name': self.site.family.name, 'wiki_code': self.site.code}) self.password = password + self.login_name = self.username if getattr(config, 'password_file', ''): self.readPassword()
@@ -123,7 +127,14 @@ @raises NoUsername: Username doesnt exist in user list. """ # convert any Special:BotPassword usernames to main account equivalent - main_username = self.username.partition('@')[0] + main_username = self.username + if '@' in self.username: + warn( + 'When using BotPasswords it is recommended that you store your ' + 'login credentials in a password_file instead. ' + 'See https://www.mediawiki.org/wiki/Manual:Pywikibot/BotPasswords ' + 'for instructions and more information.') + main_username = self.username.partition('@')[0]
try: data = self.site.allusers(start=main_username, total=1) @@ -207,6 +218,8 @@ 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. + The file must be either encoded in ASCII or UTF-8.
Example: @@ -215,6 +228,7 @@ (u"my_sysop_user", u"my_sysop_password") (u"wikipedia", u"my_wikipedia_user", u"my_wikipedia_pass") (u"en", u"wikipedia", u"my_en_wikipedia_user", u"my_en_wikipedia_pass") + (u"my_username", BotPassword(u"my_BotPassword_suffix", u"my_BotPassword_password")) """ # We fix password file permission first, # lift upper permission (regular file) from st_mode @@ -249,7 +263,13 @@ if (normalize_username(entry[1]) == self.username and entry[2] == self.site.family.name and entry[3] == self.site.code): - self.password = entry[0] + if isinstance(entry[0], basestring): + self.password = entry[0] + elif isinstance(entry[0], BotPassword): + self.password = entry[0].password + self.login_name = entry[0].login_name(self.username) + else: + warn('Invalid password format', _PasswordFileWarning) password_f.close()
def login(self, retry=False): @@ -270,21 +290,21 @@ # password = True self.password = pywikibot.input( u'Password for user %(name)s on %(site)s (no characters will ' - u'be shown):' % {'name': self.username, 'site': self.site}, + u'be shown):' % {'name': self.login_name, 'site': self.site}, password=True)
pywikibot.output(u"Logging in to %(site)s as %(name)s" - % {'name': self.username, 'site': self.site}) + % {'name': self.login_name, 'site': self.site}) try: cookiedata = self.getCookie() except pywikibot.data.api.APIError as e: pywikibot.error(u"Login failed (%s)." % e.code) if e.code == 'NotExists': raise NoUsername(u"Username '%s' does not exist on %s" - % (self.username, self.site)) + % (self.login_name, self.site)) elif e.code == 'Illegal': raise NoUsername(u"Username '%s' is invalid on %s" - % (self.username, self.site)) + % (self.login_name, self.site)) # TODO: investigate other unhandled API codes (bug T75539) if retry: self.password = None @@ -311,6 +331,41 @@ pass
+class BotPassword(object): + + """BotPassword object for storage in password file.""" + + def __init__(self, suffix, password): + """ + Constructor. + + BotPassword function by using a separate password paired with a suffixed + username of the form <username>@<suffix>. + + @param suffix: Suffix of the login name + @type suffix: basestring + @param password: bot password + @type password: basestring + + @raises _PasswordFileWarning: suffix improperly specified + """ + if '@' in suffix: + warn('The BotPassword entry should only include the suffix', + _PasswordFileWarning) + self.suffix = suffix + self.password = password + + def login_name(self, username): + """ + Construct the login name from the username and suffix. + + @param user: username (without suffix) + @type user: basestring + @rtype: basestring + """ + return '{0}@{1}'.format(username, self.suffix) + + class OauthLoginManager(LoginManager):
"""Site login manager using OAuth.""" diff --git a/setup.py b/setup.py index 2ff038e..06f3f03 100644 --- a/setup.py +++ b/setup.py @@ -44,7 +44,7 @@ if not python_is_supported(): raise RuntimeError(versions_required_message % sys.version)
-test_deps = ['bz2file'] +test_deps = ['bz2file', 'mock']
dependencies = ['requests']
diff --git a/tests/login_tests.py b/tests/login_tests.py new file mode 100644 index 0000000..3c03c47 --- /dev/null +++ b/tests/login_tests.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +""" +Tests for LoginManager classes. + +e.g. used to test password-file based login. +""" +# +# (C) Pywikibot team, 2012-2016 +# +# Distributed under the terms of the MIT license. +# +from __future__ import absolute_import, unicode_literals + +__version__ = '$Id$' +# +import mock + +from pywikibot.login import LoginManager + +from tests.aspects import ( + unittest, + DefaultDrySiteTestCase, +) + + +class FakeFamily(object): + """Mock.""" + + name = "~FakeFamily" + + +class FakeSite(object): + """Mock.""" + + code = "~FakeCode" + family = FakeFamily + +FakeUsername = "~FakeUsername" + + +class FakeConfig(object): + """Mock.""" + + usernames = { + FakeFamily.name: { + FakeSite.code: FakeUsername + } + } + + +@mock.patch("pywikibot.Site", FakeSite) +@mock.patch("pywikibot.login.config", FakeConfig) +class TestOfflineLoginManager(DefaultDrySiteTestCase): + """Test offline operation of login.LoginManager.""" + + dry = True + + def test_default_init(self): + """Test initialization of LoginManager without parameters.""" + obj = LoginManager() + self.assertIsInstance(obj.site, FakeSite) + self.assertEqual(obj.username, FakeUsername) + self.assertEqual(obj.login_name, FakeUsername) + self.assertIsNone(obj.password) + + +@mock.patch("pywikibot.Site", FakeSite) +class TestPasswordFile(DefaultDrySiteTestCase): + """Test parsing password files.""" + + def patch(self, name): + """Patch up <name> in self.setUp.""" + patcher = mock.patch(name) + self.addCleanup(patcher.stop) + return patcher.start() + + def _file_lines(self, lines=[]): + for line in lines: + yield line + + def setUp(self): + """Patch a variety of dependencies.""" + super(TestPasswordFile, self).setUp() + self.config = self.patch("pywikibot.login.config") + self.config.usernames = FakeConfig.usernames + self.config.password_file = "~FakeFile" + self.config.private_files_permission = 0o600 + self.config.base_dir = "" # ensure that no path modifies password_file + + self.stat = self.patch("os.stat") + self.stat.return_value.st_mode = 0o100600 + + self.chmod = self.patch("os.chmod") + + self.open = self.patch("codecs.open") + self.open.return_value = self._file_lines() + + def test_auto_chmod_OK(self): + """Do not chmod files that have mode private_files_permission.""" + self.stat.return_value.st_mode = 0o100600 + LoginManager() + self.stat.assert_called_with(self.config.password_file) + self.assertFalse(self.chmod.called) + + def test_auto_chmod_not_OK(self): + """Chmod files that do not have mode private_files_permission.""" + self.stat.return_value.st_mode = 0o100644 + LoginManager() + self.stat.assert_called_with(self.config.password_file) + self.chmod.assert_called_once_with( + self.config.password_file, + 0o600 + ) + + def _test_pwfile(self, contents, password): + self.open.return_value = self._file_lines(contents.split("\n")) + obj = LoginManager() + self.assertEqual(obj.password, password) + return obj + + def test_none_matching(self): + """No matching passwords.""" + self._test_pwfile(""" + ('NotTheUsername', 'NotThePassword') + """, None) + + def test_match_global_username(self): + """Test global username/password declaration.""" + self._test_pwfile(""" + ('~FakeUsername', '~FakePassword') + """, "~FakePassword") + + def test_match_family_username(self): + """Test matching by family.""" + self._test_pwfile(""" + ('~FakeFamily', '~FakeUsername', '~FakePassword') + """, "~FakePassword") + + def test_match_code_username(self): + """Test matching by full configuration.""" + self._test_pwfile(""" + ('~FakeCode', '~FakeFamily', '~FakeUsername', '~FakePassword') + """, "~FakePassword") + + def test_ordering(self): + """Test that the last matching password is selected.""" + self._test_pwfile(""" + ('~FakeCode', '~FakeFamily', '~FakeUsername', '~FakePasswordA') + ('~FakeUsername', '~FakePasswordB') + """, "~FakePasswordB") + + self._test_pwfile(""" + ('~FakeUsername', '~FakePasswordA') + ('~FakeCode', '~FakeFamily', '~FakeUsername', '~FakePasswordB') + """, "~FakePasswordB") + + def test_BotPassword(self): + """Test BotPassword entries. + + When a BotPassword is used, the login_name changes to contain a suffix, + while the password is read from an object (instead of being read from + the password file directly). + """ + obj = self._test_pwfile(""" + ('~FakeUsername', BotPassword('~FakeSuffix', '~FakePassword')) + """, '~FakePassword') + self.assertEqual(obj.login_name, "~FakeUsername@~FakeSuffix") + +if __name__ == '__main__': # pragma: no cover + unittest.main() diff --git a/tox.ini b/tox.ini index 83ce047..879cd35 100644 --- a/tox.ini +++ b/tox.ini @@ -74,6 +74,7 @@ nose nose-detecthttp unicodecsv + mock
[testenv:nose34] basepython = python3 @@ -87,6 +88,7 @@ nose nose-detecthttp>=0.1.3 six + mock
[testenv:doctest] commands =
pywikibot-commits@lists.wikimedia.org