mirror of
https://github.com/status-im/PyGithub.git
synced 2026-08-31 19:01:15 +00:00
414 lines
17 KiB
Python
414 lines
17 KiB
Python
############################ Copyrights and license ############################
|
|
# #
|
|
# Copyright 2022 Enrico Minack <github@enrico.minack.dev> #
|
|
# #
|
|
# This file is part of PyGithub. #
|
|
# http://pygithub.readthedocs.io/ #
|
|
# #
|
|
# PyGithub is free software: you can redistribute it and/or modify it under #
|
|
# the terms of the GNU Lesser General Public License as published by the Free #
|
|
# Software Foundation, either version 3 of the License, or (at your option) #
|
|
# any later version. #
|
|
# #
|
|
# PyGithub is distributed in the hope that it will be useful, but WITHOUT ANY #
|
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS #
|
|
# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more #
|
|
# details. #
|
|
# #
|
|
# You should have received a copy of the GNU Lesser General Public License #
|
|
# along with PyGithub. If not, see <http://www.gnu.org/licenses/>. #
|
|
# #
|
|
################################################################################
|
|
import contextlib
|
|
from datetime import datetime, timedelta, timezone
|
|
from unittest import mock
|
|
|
|
import github
|
|
|
|
from . import Framework
|
|
|
|
REPO_NAME = "PyGithub/PyGithub"
|
|
|
|
|
|
class Requester(Framework.TestCase):
|
|
logger = None
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.logger = mock.MagicMock()
|
|
github.Requester.Requester.injectLogger(self.logger)
|
|
|
|
def tearDown(self):
|
|
github.Requester.Requester.resetLogger()
|
|
super().tearDown()
|
|
|
|
def testRecreation(self):
|
|
class TestAuth(github.Auth.AppAuth):
|
|
pass
|
|
|
|
# create a Requester with non-default arguments
|
|
auth = TestAuth(123, "key")
|
|
requester = github.Requester.Requester(
|
|
auth=auth,
|
|
base_url="https://base.url",
|
|
timeout=1,
|
|
user_agent="user agent",
|
|
per_page=123,
|
|
verify=False,
|
|
retry=3,
|
|
pool_size=5,
|
|
seconds_between_requests=1.2,
|
|
seconds_between_writes=3.4,
|
|
)
|
|
kwargs = requester.kwargs
|
|
|
|
# assert kwargs consists of ALL constructor arguments
|
|
self.assertEqual(kwargs.keys(), github.Requester.Requester.__init__.__annotations__.keys())
|
|
self.assertEqual(
|
|
kwargs,
|
|
dict(
|
|
auth=auth,
|
|
base_url="https://base.url",
|
|
timeout=1,
|
|
user_agent="user agent",
|
|
per_page=123,
|
|
verify=False,
|
|
retry=3,
|
|
pool_size=5,
|
|
seconds_between_requests=1.2,
|
|
seconds_between_writes=3.4,
|
|
),
|
|
)
|
|
|
|
# create a copy Requester, assert identity via kwargs
|
|
copy = github.Requester.Requester(**kwargs)
|
|
self.assertEqual(copy.kwargs, kwargs)
|
|
|
|
# create Github instance, assert identity requester
|
|
gh = github.Github(**kwargs)
|
|
self.assertEqual(gh._Github__requester.kwargs, kwargs)
|
|
|
|
# create GithubIntegration instance, assert identity requester
|
|
gi = github.GithubIntegration(**kwargs)
|
|
self.assertEqual(gi._GithubIntegration__requester.kwargs, kwargs)
|
|
|
|
def testWithAuth(self):
|
|
class TestAuth(github.Auth.AppAuth):
|
|
pass
|
|
|
|
# create a Requester with non-default arguments
|
|
auth = TestAuth(123, "key")
|
|
requester = github.Requester.Requester(
|
|
auth=auth,
|
|
base_url="https://base.url",
|
|
timeout=1,
|
|
user_agent="user agent",
|
|
per_page=123,
|
|
verify=False,
|
|
retry=3,
|
|
pool_size=5,
|
|
seconds_between_requests=1.2,
|
|
seconds_between_writes=3.4,
|
|
)
|
|
|
|
# create a copy with different auth
|
|
auth2 = TestAuth(456, "key2")
|
|
copy = requester.withAuth(auth2)
|
|
|
|
# assert kwargs of copy
|
|
self.assertEqual(
|
|
copy.kwargs,
|
|
dict(
|
|
auth=auth2,
|
|
base_url="https://base.url",
|
|
timeout=1,
|
|
user_agent="user agent",
|
|
per_page=123,
|
|
verify=False,
|
|
retry=3,
|
|
pool_size=5,
|
|
seconds_between_requests=1.2,
|
|
seconds_between_writes=3.4,
|
|
),
|
|
)
|
|
|
|
def testLoggingRedirection(self):
|
|
self.assertEqual(self.g.get_repo("EnricoMi/test").name, "test-renamed")
|
|
self.logger.info.assert_called_once_with(
|
|
"Following Github server redirection from /repos/EnricoMi/test to /repositories/638123443"
|
|
)
|
|
|
|
def testBaseUrlSchemeRedirection(self):
|
|
gh = github.Github(base_url="http://api.github.com")
|
|
with self.assertRaises(RuntimeError) as exc:
|
|
gh.get_repo("PyGithub/PyGithub")
|
|
self.assertEqual(
|
|
exc.exception.args,
|
|
(
|
|
"Github server redirected from http protocol to https, please correct your "
|
|
"Github server URL via base_url: Github(base_url=...)",
|
|
),
|
|
)
|
|
|
|
def testBaseUrlHostRedirection(self):
|
|
gh = github.Github(base_url="https://www.github.com")
|
|
with self.assertRaises(RuntimeError) as exc:
|
|
gh.get_repo("PyGithub/PyGithub")
|
|
self.assertEqual(
|
|
exc.exception.args,
|
|
(
|
|
"Github server redirected from host www.github.com to github.com, "
|
|
"please correct your Github server URL via base_url: Github(base_url=...)",
|
|
),
|
|
)
|
|
|
|
def testBaseUrlPortRedirection(self):
|
|
# replay data forged
|
|
gh = github.Github(base_url="https://api.github.com")
|
|
with self.assertRaises(RuntimeError) as exc:
|
|
gh.get_repo("PyGithub/PyGithub")
|
|
self.assertEqual(
|
|
exc.exception.args,
|
|
(
|
|
"Requested https://api.github.com/repos/PyGithub/PyGithub but server "
|
|
"redirected to https://api.github.com:443/repos/PyGithub/PyGithub, "
|
|
"you may need to correct your Github server URL "
|
|
"via base_url: Github(base_url=...)",
|
|
),
|
|
)
|
|
|
|
def testBaseUrlPrefixRedirection(self):
|
|
# replay data forged
|
|
gh = github.Github(base_url="https://api.github.com/api/v3")
|
|
self.assertEqual(gh.get_repo("PyGithub/PyGithub").name, "PyGithub")
|
|
self.logger.info.assert_called_once_with(
|
|
"Following Github server redirection from /api/v3/repos/PyGithub/PyGithub to /repos/PyGithub/PyGithub"
|
|
)
|
|
|
|
PrimaryRateLimitErrors = [
|
|
"API rate limit exceeded for x.x.x.x. (But here's the good news: Authenticated requests get a higher rate limit. Check out the documentation for more details.)",
|
|
]
|
|
SecondaryRateLimitErrors = [
|
|
"You have triggered an abuse detection mechanism. Please wait a few minutes before you try again.",
|
|
"You have triggered an abuse detection mechanism and have been temporarily blocked from content creation. Please retry your request again later."
|
|
"You have exceeded a secondary rate limit and have been temporarily blocked from content creation. Please retry your request again later.",
|
|
"You have exceeded a secondary rate limit. Please wait a few minutes before you try again.",
|
|
"Something else here. Please wait a few minutes before you try again.",
|
|
]
|
|
OtherErrors = ["User does not exist or is not a member of the organization"]
|
|
|
|
def testIsRateLimitError(self):
|
|
for message in self.PrimaryRateLimitErrors + self.SecondaryRateLimitErrors:
|
|
self.assertTrue(github.Requester.Requester.isRateLimitError(message), message)
|
|
for message in self.OtherErrors:
|
|
self.assertFalse(github.Requester.Requester.isRateLimitError(message), message)
|
|
|
|
def testIsPrimaryRateLimitError(self):
|
|
for message in self.PrimaryRateLimitErrors:
|
|
self.assertTrue(github.Requester.Requester.isPrimaryRateLimitError(message), message)
|
|
for message in self.OtherErrors + self.SecondaryRateLimitErrors:
|
|
self.assertFalse(github.Requester.Requester.isPrimaryRateLimitError(message), message)
|
|
|
|
def testIsSecondaryRateLimitError(self):
|
|
for message in self.SecondaryRateLimitErrors:
|
|
self.assertTrue(github.Requester.Requester.isSecondaryRateLimitError(message), message)
|
|
for message in self.OtherErrors + self.PrimaryRateLimitErrors:
|
|
self.assertFalse(github.Requester.Requester.isSecondaryRateLimitError(message), message)
|
|
|
|
def assertException(self, exception, exception_type, status, data, headers, string):
|
|
self.assertIsInstance(exception, exception_type)
|
|
self.assertEqual(exception.status, status)
|
|
if data is None:
|
|
self.assertIsNone(exception.data)
|
|
else:
|
|
self.assertEqual(exception.data, data)
|
|
self.assertEqual(exception.headers, headers)
|
|
self.assertEqual(str(exception), string)
|
|
|
|
def testShouldCreateBadCredentialsException(self):
|
|
exc = self.g._Github__requester.createException(401, {"header": "value"}, {"message": "Bad credentials"})
|
|
self.assertException(
|
|
exc,
|
|
github.BadCredentialsException,
|
|
401,
|
|
{"message": "Bad credentials"},
|
|
{"header": "value"},
|
|
'401 {"message": "Bad credentials"}',
|
|
)
|
|
|
|
def testShouldCreateTwoFactorException(self):
|
|
exc = self.g._Github__requester.createException(
|
|
401,
|
|
{"x-github-otp": "required; app"},
|
|
{
|
|
"message": "Must specify two-factor authentication OTP code.",
|
|
"documentation_url": "https://developer.github.com/v3/auth#working-with-two-factor-authentication",
|
|
},
|
|
)
|
|
self.assertException(
|
|
exc,
|
|
github.TwoFactorException,
|
|
401,
|
|
{
|
|
"message": "Must specify two-factor authentication OTP code.",
|
|
"documentation_url": "https://developer.github.com/v3/auth#working-with-two-factor-authentication",
|
|
},
|
|
{"x-github-otp": "required; app"},
|
|
'401 {"message": "Must specify two-factor authentication OTP code.", "documentation_url": "https://developer.github.com/v3/auth#working-with-two-factor-authentication"}',
|
|
)
|
|
|
|
def testShouldCreateBadUserAgentException(self):
|
|
exc = self.g._Github__requester.createException(
|
|
403,
|
|
{"header": "value"},
|
|
{"message": "Missing or invalid User Agent string"},
|
|
)
|
|
self.assertException(
|
|
exc,
|
|
github.BadUserAgentException,
|
|
403,
|
|
{"message": "Missing or invalid User Agent string"},
|
|
{"header": "value"},
|
|
'403 {"message": "Missing or invalid User Agent string"}',
|
|
)
|
|
|
|
def testShouldCreateRateLimitExceededException(self):
|
|
for message in self.PrimaryRateLimitErrors + self.SecondaryRateLimitErrors:
|
|
with self.subTest(message=message):
|
|
exc = self.g._Github__requester.createException(403, {"header": "value"}, {"message": message})
|
|
self.assertException(
|
|
exc,
|
|
github.RateLimitExceededException,
|
|
403,
|
|
{"message": message},
|
|
{"header": "value"},
|
|
f'403 {{"message": "{message}"}}',
|
|
)
|
|
|
|
def testShouldCreateUnknownObjectException(self):
|
|
exc = self.g._Github__requester.createException(404, {"header": "value"}, {"message": "Not Found"})
|
|
self.assertException(
|
|
exc,
|
|
github.UnknownObjectException,
|
|
404,
|
|
{"message": "Not Found"},
|
|
{"header": "value"},
|
|
'404 {"message": "Not Found"}',
|
|
)
|
|
|
|
def testShouldCreateGithubException(self):
|
|
for status in range(400, 600):
|
|
with self.subTest(status=status):
|
|
exc = self.g._Github__requester.createException(
|
|
status, {"header": "value"}, {"message": "Something unknown"}
|
|
)
|
|
self.assertException(
|
|
exc,
|
|
github.GithubException,
|
|
status,
|
|
{"message": "Something unknown"},
|
|
{"header": "value"},
|
|
f'{status} {{"message": "Something unknown"}}',
|
|
)
|
|
|
|
def testShouldCreateExceptionWithoutMessage(self):
|
|
for status in range(400, 600):
|
|
with self.subTest(status=status):
|
|
exc = self.g._Github__requester.createException(status, {}, {})
|
|
self.assertException(exc, github.GithubException, status, {}, {}, f"{status} {{}}")
|
|
|
|
def testShouldCreateExceptionWithoutOutput(self):
|
|
for status in range(400, 600):
|
|
with self.subTest(status=status):
|
|
exc = self.g._Github__requester.createException(status, {}, None)
|
|
self.assertException(exc, github.GithubException, status, None, {}, f"{status}")
|
|
|
|
|
|
class RequesterThrottleTestCase(Framework.TestCase):
|
|
per_page = 10
|
|
|
|
mock_time = [datetime.now(timezone.utc)]
|
|
|
|
def sleep(self, seconds):
|
|
self.mock_time[0] = self.mock_time[0] + timedelta(seconds=seconds)
|
|
|
|
def now(self, tz=None):
|
|
return self.mock_time[0]
|
|
|
|
@contextlib.contextmanager
|
|
def mock_sleep(self):
|
|
with mock.patch("github.Requester.time.sleep", side_effect=self.sleep) as sleep_mock, mock.patch(
|
|
"github.Requester.datetime"
|
|
) as datetime_mock:
|
|
datetime_mock.now = self.now
|
|
yield sleep_mock
|
|
|
|
|
|
class RequesterUnThrottled(RequesterThrottleTestCase):
|
|
def testShouldNotDeferRequests(self):
|
|
with self.mock_sleep() as sleep_mock:
|
|
# same test setup as in RequesterThrottled.testShouldDeferRequests
|
|
repository = self.g.get_repo(REPO_NAME)
|
|
releases = list(repository.get_releases())
|
|
self.assertEqual(len(releases), 30)
|
|
|
|
sleep_mock.assert_not_called()
|
|
|
|
|
|
class RequesterThrottled(RequesterThrottleTestCase):
|
|
seconds_between_requests = 1.0
|
|
seconds_between_writes = 3.0
|
|
|
|
def testShouldDeferRequests(self):
|
|
with self.mock_sleep() as sleep_mock:
|
|
# same test setup as in RequesterUnThrottled.testShouldNotDeferRequests
|
|
repository = self.g.get_repo(REPO_NAME)
|
|
releases = [release for release in repository.get_releases()]
|
|
self.assertEqual(len(releases), 30)
|
|
|
|
self.assertEqual(sleep_mock.call_args_list, [mock.call(1), mock.call(1), mock.call(1)])
|
|
|
|
def testShouldDeferWrites(self):
|
|
with self.mock_sleep() as sleep_mock:
|
|
# same test setup as in AuthenticatedUser.testEmail
|
|
user = self.g.get_user()
|
|
emails = user.get_emails()
|
|
self.assertEqual(
|
|
[item.email for item in emails],
|
|
["vincent@vincent-jacques.net", "github.com@vincent-jacques.net"],
|
|
)
|
|
self.assertTrue(emails[0].primary)
|
|
self.assertTrue(emails[0].verified)
|
|
self.assertEqual(emails[0].visibility, "private")
|
|
user.add_to_emails("1@foobar.com", "2@foobar.com")
|
|
self.assertEqual(
|
|
[item.email for item in user.get_emails()],
|
|
[
|
|
"vincent@vincent-jacques.net",
|
|
"1@foobar.com",
|
|
"2@foobar.com",
|
|
"github.com@vincent-jacques.net",
|
|
],
|
|
)
|
|
user.remove_from_emails("1@foobar.com", "2@foobar.com")
|
|
self.assertEqual(
|
|
[item.email for item in user.get_emails()],
|
|
["vincent@vincent-jacques.net", "github.com@vincent-jacques.net"],
|
|
)
|
|
|
|
self.assertEqual(
|
|
sleep_mock.call_args_list,
|
|
[
|
|
# g.get_user() does not call into GitHub API
|
|
# user.get_emails() is the first request so no waiting needed
|
|
# user.add_to_emails is a write request, this is the first write request
|
|
mock.call(1),
|
|
# user.get_emails() is a read request
|
|
mock.call(1),
|
|
# user.remove_from_emails is a write request, it has to be 3 seconds after the last write
|
|
mock.call(2),
|
|
# user.get_emails() is a read request
|
|
mock.call(1),
|
|
],
|
|
)
|