John Vandenberg has uploaded a new change for review. https://gerrit.wikimedia.org/r/237977
Change subject: Expand login and rights exception tree ...................................................................... Expand login and rights exception tree Allow APIError, with extra information, to be used as subclasses for core exceptions like NoUsername, so that uncaught errors include API supplied information like `servedby`. Bug: T109173 Change-Id: I7f1119f2ccea48221ca400958683fd5de13ee34d --- M pywikibot/data/api.py M pywikibot/exceptions.py M pywikibot/login.py M pywikibot/site.py M scripts/category.py M scripts/interwiki.py M scripts/redirect.py M tests/exceptions_tests.py 8 files changed, 394 insertions(+), 81 deletions(-) git pull ssh://gerrit.wikimedia.org:29418/pywikibot/core refs/changes/77/237977/1 diff --git a/pywikibot/data/api.py b/pywikibot/data/api.py index e574227..a8cc216 100644 --- a/pywikibot/data/api.py +++ b/pywikibot/data/api.py @@ -28,12 +28,21 @@ from warnings import warn import pywikibot + from pywikibot import config, login -from pywikibot.tools import MediaWikiVersion, deprecated, itergroup, ip, PY2 -from pywikibot.exceptions import ( - Server504Error, Server414Error, FatalServerError, NoUsername, Error -) + from pywikibot.comms import http +from pywikibot.exceptions import ( + Server504Error, Server414Error, FatalServerError, + SiteRelatedError, + LoginError, + OAuthAuthenticationError, + Error, +) +from pywikibot.tools import ( + MediaWikiVersion, itergroup, ip, PY2, + deprecated, issue_deprecation_warning, +) if not PY2: # Subclassing necessary to fix a possible bug of the email package @@ -87,23 +96,31 @@ lagpattern = re.compile(r"Waiting for [\d.]+: (?P<lag>\d+) seconds? lagged") -class APIError(Error): +class APIError(SiteRelatedError): """The wiki site returned an error message.""" - def __init__(self, code, info, **kwargs): + def __init__(self, code, info, site=None, **kwargs): """Save error dict returned by MW API.""" self.code = code self.info = info self.other = kwargs - self.unicode = unicode(self.__str__()) + self.unicode = unicode(self) + message = self.unicode + + if site: + super(APIError, self).__init__(site, message) + else: + issue_deprecation_warning( + 'APIError without a site', instead=None, depth=2) + super(APIError, self).__init__(message=message) def __repr__(self): """Return internal representation.""" return '{name}("{code}", "{info}", {other})'.format( name=self.__class__.__name__, **self.__dict__) - def __str__(self): + def __unicode__(self): """Return a string representation.""" if self.other: return '{0}: {1} [{2}]'.format( @@ -120,7 +137,7 @@ """Upload failed with a warning message (passed as the argument).""" - def __init__(self, code, message, file_key=None, offset=0): + def __init__(self, code, message, file_key=None, offset=0, site=None): """ Create a new UploadWarning instance. @@ -131,7 +148,7 @@ there is no offset. @type offset: int or bool """ - super(UploadWarning, self).__init__(code, message) + super(UploadWarning, self).__init__(code, message, site) self.file_key = file_key self.offset = offset @@ -150,6 +167,16 @@ self.mediawiki_exception_class_name = mediawiki_exception_class_name code = 'internal_api_error_' + mediawiki_exception_class_name super(APIMWException, self).__init__(code, info, **kwargs) + + +class APILoginError(APIError, LoginError): + + """Login error during an API request.""" + + +class APIOAuthInvalidAuthorisation(APIError, OAuthAuthenticationError): + + """OAuth failure during an API request.""" class ParamInfo(Container): @@ -2150,10 +2177,12 @@ self.site.user(), ', '.join('{0}: {1}'.format(*e) for e in user_tokens.items()))) + if 'mwoauth-invalid-authorization' in code: - raise NoUsername('Failed OAuth authentication for %s: %s' - % (self.site, info)) - # raise error + raise APIOAuthInvalidAuthorisation(site=self.site, **error) + elif code == 'readapidenied': + raise APILoginError(site=self.site, **error) + try: # Due to bug T66958, Page's repr may return non ASCII bytes # Get as bytes in PY2 and decode with the console encoding as diff --git a/pywikibot/exceptions.py b/pywikibot/exceptions.py index a8a8beb..221f191 100644 --- a/pywikibot/exceptions.py +++ b/pywikibot/exceptions.py @@ -3,10 +3,7 @@ Exception and warning classes used throughout the framework. Error: Base class, all exceptions should the subclass of this class. - - NoUsername: Username is not in user-config.py, or it is invalid. - - UserBlocked: Username or IP has been blocked - AutoblockUser: requested action on a virtual autoblock user not valid - - UserRightsError: insufficient rights for requested action - BadTitle: Server responded with BadTitle - InvalidTitle: Invalid page title - CaptchaError: Captcha is asked and config.solve_captcha == False @@ -14,6 +11,13 @@ - PageNotFound: Page not found (deprecated) - i18n.TranslationError: i18n/l10n message not available - UnknownExtension: Extension is not defined for this site + +---NotLoggedIn: A login was not possible + - NoUsername: Username is not in user-config.py, or it is invalid. + - LoginFailed: Login was not successful + - OAuthLoginFailure: OAuth login failure + - login.OAuthImpossible: OAuth dependencies failed + - data.api.OAuthInvalidAuthorisation: OAuth authorisation failed SiteDefinitionError: Site loading problem - UnknownSite: Site does not exist in Family @@ -66,7 +70,7 @@ - FamilyMaintenanceWarning: missing information in family definition """ # -# (C) Pywikibot team, 2008 +# (C) Pywikibot team, 2008-2015 # # Distributed under the terms of the MIT license. # @@ -76,9 +80,14 @@ import sys -from pywikibot.tools import UnicodeMixin, _NotImplementedWarning +from pywikibot.tools import ( + PY2, + UnicodeMixin, + _NotImplementedWarning, + issue_deprecation_warning, +) -if sys.version_info[0] > 2: +if not PY2: unicode = str @@ -111,13 +120,57 @@ def __init__(self, arg): """Constructor.""" self.unicode = arg + super(Error, self).__init__(arg) def __unicode__(self): """Return a unicode string representation.""" return self.unicode -class PageRelatedError(Error): +class SiteRelatedError(Error): + + """ + Abstract Exception, used when the exception concerns a particular Site. + + This class should be used when the Exception concerns a particular + Site, and when a generic message can be written once for all. + """ + + def __init__(self, site=None, message=None): + """ + Constructor. + + @param page: Site that caused the exception + @type page: Site object + """ + if message: + try: + self.message = message + except AttributeError: + pass + + if not site: + issue_deprecation_warning( + 'SiteRelatedError(site=None)', instead=None, depth=2) + self.site = self.family = self.site_code = 'unknown' + else: + self.site = site + self.family = self.site.family + self.site_code = self.site.code + + if hasattr(self, 'message'): + if '%(' in self.message and ')s' in self.message: + super(SiteRelatedError, self).__init__( + self.message % self.__dict__) + elif '%s' in self.message: + super(SiteRelatedError, self).__init__(self.message % site) + else: + super(SiteRelatedError, self).__init__(self.message) + else: + super(SiteRelatedError, self).__init__() + + +class PageRelatedError(SiteRelatedError): """ Abstract Exception, used when the exception concerns a particular Page. @@ -146,12 +199,11 @@ self.page = page self.title = page.title(asLink=True) - self.site = page.site - if '%(' in self.message and ')s' in self.message: - super(PageRelatedError, self).__init__(self.message % self.__dict__) - else: - super(PageRelatedError, self).__init__(self.message % page) + if '%s' in self.message: + self.message = self.message % page + + super(PageRelatedError, self).__init__(page.site) def getPage(self): """Return the page related to the exception.""" @@ -195,11 +247,68 @@ return unicode(self.reason) -class NoUsername(Error): +class UserRightsError(Error): + + """Insufficient user rights to perform an action.""" + + pass + + +class LoginError(Error): + + """Unable to login.""" + + +class NoUsername(LoginError): + + """Backwards compatible exception.""" + + +class LoginNotPossible(NoUsername, LoginError): + + """Login is not possible.""" + + +class UsernameNotSpecified(LoginNotPossible, SiteRelatedError): """Username is not in user-config.py.""" - pass + message = """ +ERROR: Username for %(site)s is undefined. +If you have an account for that site, please add a line to user-config.py: + +usernames['%(family)s']['%(site_code)s'] = 'myUsername'""" + + +class _SysopnameNotSpecified(UsernameNotSpecified): + + """Sysopname is not in user-config.py.""" + + message = """ +ERROR: Sysop username for %(site)s is undefined. +If you have a sysop account for that site, please add a line to user-config.py: + +sysopnames['%(family)s']['%(site_code)s'] = 'myUsername'""" + + +class InvalidUsername(LoginNotPossible): + + """Username is not recognised.""" + + +class LoginFailed(NoUsername, LoginError): + + """Login attempt failed.""" + + +class RightsElevationFailed(LoginFailed, UserRightsError): + + """Attempt to increase user rights failed.""" + + +class OAuthAuthenticationError(LoginError): + + """OAuth authentication error.""" class NoPage(PageRelatedError): # noqa @@ -449,14 +558,14 @@ pass -class UserBlocked(Error): # noqa +class UserBlocked(UserRightsError): # noqa """Your username or IP has been blocked""" pass -class CaptchaError(Error): +class CaptchaError(LoginError): """Captcha is asked and config.solve_captcha == False.""" @@ -471,13 +580,6 @@ an action is requested on a virtual autoblock user that's not available for him (i.e. roughly everything except unblock). """ - - pass - - -class UserRightsError(Error): - - """Insufficient user rights to perform an action.""" pass diff --git a/pywikibot/login.py b/pywikibot/login.py index ac37803..8e86cfd 100644 --- a/pywikibot/login.py +++ b/pywikibot/login.py @@ -27,10 +27,16 @@ from pywikibot import config from pywikibot.tools import deprecated_args, normalize_username -from pywikibot.exceptions import NoUsername +from pywikibot.exceptions import ( + LoginError, + RightsElevationFailed, + UsernameNotSpecified, + _SysopnameNotSpecified, + InvalidUsername, +) -class OAuthImpossible(ImportError): +class OAuthImpossible(ImportError, LoginError): """OAuth authentication is not possible on your system.""" @@ -79,7 +85,7 @@ The sysop username is loaded from config.sysopnames. @type sysop: bool - @raises NoUsername: No username is configured for the requested site. + @raises UsernameNotSpecified: No username for the requested site. """ if site is not None: self.site = site @@ -93,26 +99,15 @@ self.username = family_sysopnames.get(self.site.code, None) self.username = self.username or family_sysopnames['*'] except KeyError: - raise NoUsername(u"""\ -ERROR: Sysop username for %(fam_name)s:%(wiki_code)s is undefined. -If you have a sysop account for that site, please add a line to user-config.py: - -sysopnames['%(fam_name)s']['%(wiki_code)s'] = 'myUsername'""" - % {'fam_name': self.site.family.name, - 'wiki_code': self.site.code}) + raise UsernameNotSpecified(self.site) else: try: family_usernames = config.usernames[self.site.family.name] self.username = family_usernames.get(self.site.code, None) self.username = self.username or family_usernames['*'] except: - raise NoUsername(u"""\ -ERROR: Username for %(fam_name)s:%(wiki_code)s is undefined. -If you have an account for that site, please add a line to user-config.py: + raise _SysopnameNotSpecified(self.site) -usernames['%(fam_name)s']['%(wiki_code)s'] = 'myUsername'""" - % {'fam_name': self.site.family.name, - 'wiki_code': self.site.code}) self.password = password if getattr(config, 'password_file', ''): self.readPassword() @@ -121,12 +116,12 @@ """ Check that the username exists on the site. - @raises NoUsername: Username doesnt exist in user list. + @raises InvalidUsername: Username doesnt exist in user list. """ try: data = self.site.allusers(start=self.username, total=1) user = next(iter(data)) - except pywikibot.data.api.APIError as e: + except pywikibot.data.api.APILoginError as e: if e.code == 'readapidenied': pywikibot.warning('Could not check user %s exists on %s' % (self.username, self.site)) @@ -136,8 +131,8 @@ if user['name'] != self.username: # Report the same error as server error code NotExists - raise NoUsername('Username \'%s\' is invalid on %s' - % (self.username, self.site)) + raise InvalidUsername('Username \'%s\' is invalid on %s' + % (self.username, self.site)) def botAllowed(self): """ @@ -257,7 +252,7 @@ @param retry: infinitely retry if the API returns an unknown error @type retry: bool - @raises NoUsername: Username is not recognised by the site. + @raises : Username is not recognised by the site. """ if not self.password: # First check that the username exists, @@ -278,10 +273,10 @@ 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" + raise InvalidUsername(u"Username '%s' does not exist on %s" % (self.username, self.site)) elif e.code == 'Illegal': - raise NoUsername(u"Username '%s' is invalid on %s" + raise InvalidUsername(u"Username '%s' is invalid on %s" % (self.username, self.site)) # TODO: investigate other unhandled API codes (bug 73539) if retry: @@ -332,7 +327,7 @@ The sysop username is loaded from config.sysopnames. @type sysop: bool - @raises NoUsername: No username is configured for the requested site. + @raises : No username is configured for the requested site. @raise OAuthImpossible: mwoauth isn't installed """ if isinstance(mwoauth, ImportError): diff --git a/pywikibot/site.py b/pywikibot/site.py index cbb092b..6dfa337 100644 --- a/pywikibot/site.py +++ b/pywikibot/site.py @@ -60,7 +60,8 @@ UnknownSite, UnknownExtension, FamilyMaintenanceWarning, - NoUsername, + LoginError, + RightsElevationFailed, SpamfilterError, NoCreateError, UserBlocked, @@ -1972,15 +1973,15 @@ pass if self.is_oauth_token_available(): if sysop: - raise NoUsername('No sysop is permitted with OAuth') + raise RightsElevationFailed('No sysop is permitted with OAuth') elif self.userinfo['name'] != self._username[sysop]: - raise NoUsername('Logged in on %(site)s via OAuth as %(wrong)s, ' + raise LoginError('Logged in on %(site)s via OAuth as %(wrong)s, ' 'but expect as %(right)s' % {'site': self, 'wrong': self.userinfo['name'], 'right': self._username[sysop]}) else: - raise NoUsername('Logging in on %s via OAuth failed' % self) + raise LoginFailure('Logging in on %s via OAuth failed' % self) loginMan = api.LoginManager(site=self, sysop=sysop, user=self._username[sysop]) if loginMan.login(retry=True): @@ -2879,7 +2880,7 @@ sysop_protected = "edit" in rest and rest['edit'][0] == 'sysop' try: api.LoginManager(site=self, sysop=sysop_protected) - except NoUsername: + except LoginError: return False return True @@ -4461,7 +4462,7 @@ if "deletedhistory" not in self.userinfo['rights']: try: self.login(True) - except NoUsername: + except RightsElevationFailed: pass if "deletedhistory" not in self.userinfo['rights']: raise Error( @@ -4472,7 +4473,7 @@ if "undelete" not in self.userinfo['rights']: try: self.login(True) - except NoUsername: + except RightsElevationFailed: pass if "undelete" not in self.userinfo['rights']: raise Error( diff --git a/scripts/category.py b/scripts/category.py index 2bd8973..948b6fa 100755 --- a/scripts/category.py +++ b/scripts/category.py @@ -131,6 +131,7 @@ from pywikibot.bot import ( MultipleSitesBot, IntegerOption, StandardOption, ContextOption, ) +from pywikibot.exceptions import UsernameNotSpecified from pywikibot.tools import ( deprecated_args, deprecated, ModuleDeprecationWrapper ) @@ -475,9 +476,9 @@ repo = self.site.data_repository() if self.wikibase and repo.username() is None: # The bot can't move categories nor update the Wikibase repo - raise pywikibot.NoUsername(u"The 'wikibase' option is turned on" - u" and %s has no registered username." - % repo) + raise UsernameNotSpecified( + "The 'wikibase' option is turned on " + 'and %s has no registered username.', site=repo) template_vars = {'oldcat': self.oldcat.title(withNamespace=False)} if self.newcat: diff --git a/scripts/interwiki.py b/scripts/interwiki.py index beaab4c..7f0cfdf 100755 --- a/scripts/interwiki.py +++ b/scripts/interwiki.py @@ -360,6 +360,10 @@ from pywikibot import config, i18n, pagegenerators, textlib, interwiki_graph, titletranslate from pywikibot.bot import ListOption, StandardOption +from pywikibot.exceptions import ( + LoginError, + UserRightsError, +) from pywikibot.tools import first_upper if sys.version_info[0] > 2: @@ -1706,7 +1710,7 @@ updatedSites.append(site) except SaveError: notUpdatedSites.append(site) - except pywikibot.NoUsername: + except (LoginError, UserRightsError): pass except GiveUpOnPage: break diff --git a/scripts/redirect.py b/scripts/redirect.py index 70ae9c7..4f76db2 100755 --- a/scripts/redirect.py +++ b/scripts/redirect.py @@ -83,8 +83,11 @@ import sys import datetime + import pywikibot + from pywikibot import i18n, xmlreader, Bot +from pywikibot.exceptions import UserRightsError if sys.version_info[0] > 2: basestring = (str, ) @@ -461,10 +464,10 @@ % (targetPage, movedTarget, redir_page)): try: redir_page.save(reason) - except pywikibot.NoUsername: - pywikibot.output(u"Page [[%s]] not saved; " - u"sysop privileges required." - % redir_page.title()) + except UserRightsError as e: + pywikibot.output( + 'Page [[%s]] not saved due to permissions' + % redir_page.title()) except pywikibot.LockedPage: pywikibot.output(u'%s is locked.' % redir_page.title()) diff --git a/tests/exceptions_tests.py b/tests/exceptions_tests.py index 252877b..ec9a239 100644 --- a/tests/exceptions_tests.py +++ b/tests/exceptions_tests.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """Tests for exceptions.""" # -# (C) Pywikibot team, 2014 +# (C) Pywikibot team, 2014-2015 # # Distributed under the terms of the MIT license. # @@ -11,21 +11,180 @@ import pywikibot -from tests.aspects import unittest, DeprecationTestCase +from pywikibot.data.api import APIError +from pywikibot.exceptions import ( + Error, + SiteRelatedError, + PageRelatedError, + UsernameNotSpecified, + _SysopnameNotSpecified, +) +from pywikibot.tools import PY2 + +from tests.aspects import ( + unittest, + TestCase, + DefaultSiteTestCase, + DeprecationTestCase, +) + +if not PY2: + unicode = str -class TestDeprecatedExceptions(DeprecationTestCase): +class TestError(TestCase): + + """Test base Error class.""" + + net = False + + def test_base(self): + """Test Error('foo').""" + exception = Error('foo') + self.assertEqual(str(exception), 'foo') + self.assertEqual(unicode(exception), 'foo') + self.assertEqual(exception.args, ('foo', )) + self.assertEqual(exception.message, 'foo') + + +class TestSiteRelatedError(DefaultSiteTestCase): + + """Test base Error class.""" + + dry = True + + def test_site_related_error_base(self): + exception = SiteRelatedError(self.site, 'foo') + self.assertEqual(str(exception), 'foo') + self.assertEqual(unicode(exception), 'foo') + + self.assertEqual(exception.args, ('foo', )) + self.assertEqual(exception.message, 'foo') + + self.assertEqual(exception.site, self.site) + self.assertEqual(exception.family, self.site.family.name) + self.assertEqual(exception.site_code, self.site.code) + + def test_site_related_error_percent(self): + exception = SiteRelatedError(self.site, 'foo %s bar') + expect = 'foo %s bar' % self.site + self.assertEqual(str(exception), expect) + self.assertEqual(unicode(exception), expect) + + self.assertEqual(exception.args, (expect, )) + self.assertEqual(exception.message, 'foo %s bar') + + self.assertEqual(exception.site, self.site) + self.assertEqual(exception.family, self.site.family.name) + self.assertEqual(exception.site_code, self.site.code) + + def test_site_related_error_percent_dict(self): + exception = SiteRelatedError(self.site, 'foo %(site)s bar') + expect = 'foo %s bar' % self.site + self.assertEqual(str(exception), expect) + self.assertEqual(unicode(exception), expect) + + self.assertEqual(exception.args, (expect, )) + self.assertEqual(exception.message, 'foo %(site)s bar') + + self.assertEqual(exception.site, self.site) + self.assertEqual(exception.family, self.site.family.name) + self.assertEqual(exception.site_code, self.site.code) + + +class TestPageRelatedError(DefaultSiteTestCase): + + """Test base Error class.""" + + dry = True + + def test_page_related_error_base(self): + mainpage = self.get_mainpage() + exception = PageRelatedError(mainpage, 'foo') + + self.assertEqual(str(exception), 'foo') + self.assertEqual(unicode(exception), 'foo') + + self.assertEqual(exception.args, ('foo', )) + self.assertEqual(exception.message, 'foo') + + self.assertEqual(exception.site, self.site) + + def test_page_related_error_percent(self): + mainpage = self.get_mainpage() + exception = PageRelatedError(mainpage, 'foo %s bar') + expect = 'foo %s bar' % mainpage + + self.assertEqual(str(exception), expect) + self.assertEqual(unicode(exception), expect) + + self.assertEqual(exception.args, (expect, )) + self.assertEqual(exception.message, expect) + + self.assertEqual(exception.site, self.site) + + def test_page_related_error_percent_dict_page(self): + mainpage = self.get_mainpage() + exception = PageRelatedError(mainpage, 'foo %(site)s %(page)s bar') + expect = 'foo %s %s bar' % (self.site, mainpage) + + self.assertEqual(str(exception), expect) + self.assertEqual(unicode(exception), expect) + + self.assertEqual(exception.args, (expect, )) + self.assertEqual(exception.message, 'foo %(site)s %(page)s bar') + + self.assertEqual(exception.site, self.site) + + def test_page_related_error_percent_dict_title(self): + mainpage = self.get_mainpage() + exception = PageRelatedError(mainpage, 'foo %(site)s %(title)s bar') + expect = 'foo %s %s bar' % (self.site, mainpage.title(asLink=True)) + + self.assertEqual(str(exception), expect) + self.assertEqual(unicode(exception), expect) + + self.assertEqual(exception.args, (expect, )) + self.assertEqual(exception.message, 'foo %(site)s %(title)s bar') + + self.assertEqual(exception.site, self.site) + + +class TestUsernameExceptions(DefaultSiteTestCase): + + """Test cases for username exceptions.""" + + dry = True + + def test_username_not_specified(self): + """Test message of UsernameNotSpecified.""" + exception = UsernameNotSpecified(self.site) + self.assertIn( + "usernames['{0}']['{1}'] = 'myUsername'".format( + self.site.family.name, self.site.code), + str(exception)) + + def test_sysopname_not_specified(self): + """Test message of _SysopnameNotSpecified.""" + exception = _SysopnameNotSpecified(self.site) + self.assertIn( + "sysopnames['{0}']['{1}'] = 'myUsername'".format( + self.site.family.name, self.site.code), + str(exception)) + + +class TestDeprecatedExceptions(DeprecationTestCase, DefaultSiteTestCase): """Test usage of deprecation in library code.""" - net = False + dry = False def test_UploadWarning(self): """Test exceptions.UploadWarning is deprecated only.""" # Accessing from the main package should work fine. cls = pywikibot.UploadWarning self.assertNoDeprecation() - e = cls('foo', 'bar') + e = cls('foo', 'bar', site=self.site) self.assertIsInstance(e, pywikibot.Error) self.assertNoDeprecation() @@ -37,7 +196,7 @@ self.assertOneDeprecationParts('pywikibot.exceptions.UploadWarning', 'pywikibot.data.api.UploadWarning') - e = cls('foo', 'bar') + e = cls('foo', 'bar', site=self.site) self.assertIsInstance(e, pywikibot.Error) self.assertNoDeprecation() @@ -65,6 +224,25 @@ 'pywikibot.exceptions.DeprecatedPageNotFoundError') +class TestAPIError(DefaultSiteTestCase): + + """Test base APIError class.""" + + dry = True + + def test_base(self): + exception = APIError('foo-code', 'foo-message', self.site) + self.assertEqual(str(exception), 'foo-code: foo-message') + self.assertEqual(unicode(exception), 'foo-code: foo-message') + + self.assertEqual(exception.args, ('foo-code: foo-message', )) + self.assertEqual(exception.message, 'foo-code: foo-message') + + self.assertEqual(exception.site, self.site) + self.assertEqual(exception.family, self.site.family.name) + self.assertEqual(exception.site_code, self.site.code) + + if __name__ == '__main__': try: unittest.main() -- To view, visit https://gerrit.wikimedia.org/r/237977 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: newchange Gerrit-Change-Id: I7f1119f2ccea48221ca400958683fd5de13ee34d Gerrit-PatchSet: 1 Gerrit-Project: pywikibot/core Gerrit-Branch: master Gerrit-Owner: John Vandenberg <jay...@gmail.com> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits