diff --git a/github/AccessToken.py b/github/AccessToken.py index de5d76ef..98169fa2 100644 --- a/github/AccessToken.py +++ b/github/AccessToken.py @@ -20,7 +20,7 @@ # # ################################################################################ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import github.GithubObject @@ -128,7 +128,7 @@ class AccessToken(github.GithubObject.NonCompletableGithubObject): self._refresh_expires_in = github.GithubObject.NotSet def _useAttributes(self, attributes): - self._created = datetime.utcnow() + self._created = datetime.now(timezone.utc) if "access_token" in attributes: # pragma no branch self._token = self._makeStringAttribute(attributes["access_token"]) if "token_type" in attributes: # pragma no branch diff --git a/github/Auth.py b/github/Auth.py index 5f6576ce..dc73667e 100644 --- a/github/Auth.py +++ b/github/Auth.py @@ -23,7 +23,7 @@ import abc import base64 import time -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Dict, Optional, Union import jwt @@ -307,7 +307,7 @@ class AppInstallationAuth(Auth, WithRequester["AppInstallationAuth"]): self.__installation_authorization.expires_at - TOKEN_REFRESH_THRESHOLD_TIMEDELTA ) - return token_expires_at < datetime.utcnow() + return token_expires_at < datetime.now(timezone.utc) def _get_installation_authorization(self) -> InstallationAuthorization: assert ( @@ -413,7 +413,9 @@ class AppUserAuth(Auth, WithRequester["AppUserAuth"]): @property def _is_expired(self) -> bool: - return self._expires_at is not None and self._expires_at < datetime.utcnow() + return self._expires_at is not None and self._expires_at < datetime.now( + timezone.utc + ) def _refresh(self): if self._refresh_token is None: @@ -422,7 +424,7 @@ class AppUserAuth(Auth, WithRequester["AppUserAuth"]): ) if ( self._refresh_expires_at is not None - and self._refresh_expires_at < datetime.utcnow() + and self._refresh_expires_at < datetime.now(timezone.utc) ): raise RuntimeError( "Cannot refresh expired token because refresh token also expired" diff --git a/github/AuthenticatedUser.py b/github/AuthenticatedUser.py index 8f091b6d..0639fed7 100644 --- a/github/AuthenticatedUser.py +++ b/github/AuthenticatedUser.py @@ -1229,7 +1229,9 @@ class AuthenticatedUser(github.GithubObject.CompletableGithubObject): ) return status == 200 - def mark_notifications_as_read(self, last_read_at=datetime.datetime.utcnow()): + def mark_notifications_as_read( + self, last_read_at=datetime.datetime.now(datetime.timezone.utc) + ): """ :calls: `PUT /notifications `_ :param last_read_at: datetime diff --git a/github/GithubIntegration.py b/github/GithubIntegration.py index b6bf6910..83640fa9 100644 --- a/github/GithubIntegration.py +++ b/github/GithubIntegration.py @@ -4,6 +4,7 @@ import deprecated from github import Consts from github.Auth import AppAuth +from github.GithubApp import GithubApp from github.GithubException import GithubException from github.Installation import Installation from github.InstallationAuthorization import InstallationAuthorization @@ -71,7 +72,10 @@ class GithubIntegration: jwt_algorithm=jwt_algorithm, ) - assert auth is not None + assert isinstance( + auth, AppAuth + ), f"GithubIntegration requires github.Auth.AppAuth authentication, not {type(auth)}" + self.auth = auth self.__requester = Requester( @@ -213,3 +217,16 @@ class GithubIntegration: :rtype: :class:`github.Installation.Installation` """ return self._get_installed_app(url=f"/app/installations/{installation_id}") + + def get_app(self): + """ + :calls: `GET /app `_ + :rtype: :class:`github.GithubApp.GithubApp` + """ + + headers, data = self.__requester.requestJsonAndCheck( + "GET", "/app", headers=self._get_headers() + ) + return GithubApp( + requester=self.__requester, headers=headers, attributes=data, completed=True + ) diff --git a/github/MainClass.py b/github/MainClass.py index c5eb1a4b..a5887153 100644 --- a/github/MainClass.py +++ b/github/MainClass.py @@ -808,8 +808,12 @@ class Github: if slug is github.GithubObject.NotSet: # with no slug given, calling /app returns the authenticated app, # including the actual /apps/{slug} - headers, data = self.__requester.requestJsonAndCheck("GET", "/app") - return GithubApp.GithubApp(self.__requester, headers, data, completed=True) + warnings.warn( + "Argument slug is mandatory, calling this method without the slug argument is deprecated, please use " + "github.GithubIntegration(auth=github.Auth.AppAuth(...)).get_app() instead", + category=DeprecationWarning, + ) + return GithubIntegration(auth=self.__requester.auth).get_app() else: # with a slug given, we can lazily load the GithubApp return GithubApp.GithubApp( diff --git a/github/Repository.py b/github/Repository.py index 9e84080a..bd381b68 100644 --- a/github/Repository.py +++ b/github/Repository.py @@ -3702,7 +3702,9 @@ class Repository(github.GithubObject.CompletableGithubObject): params, ) - def mark_notifications_as_read(self, last_read_at=datetime.datetime.utcnow()): + def mark_notifications_as_read( + self, last_read_at=datetime.datetime.now(datetime.timezone.utc) + ): """ :calls: `PUT /repos/{owner}/{repo}/notifications `_ :param last_read_at: datetime diff --git a/tests/ApplicationOAuth.py b/tests/ApplicationOAuth.py index b99358d4..2102a9ae 100644 --- a/tests/ApplicationOAuth.py +++ b/tests/ApplicationOAuth.py @@ -83,8 +83,10 @@ class ApplicationOAuth(Framework.TestCase): def testGetAccessTokenWithExpiry(self): with mock.patch("github.AccessToken.datetime") as dt: - dt.utcnow = mock.Mock( - return_value=datetime.datetime(2023, 6, 7, 12, 0, 0, 123) + dt.now = mock.Mock( + return_value=datetime.datetime( + 2023, 6, 7, 12, 0, 0, 123, tzinfo=datetime.timezone.utc + ) ) access_token = self.app.get_access_token( "oauth_code_removed", state="state_removed" @@ -100,13 +102,14 @@ class ApplicationOAuth(Framework.TestCase): self.assertEqual(access_token.scope, "") self.assertEqual(access_token.expires_in, 28800) self.assertEqual( - access_token.expires_at, datetime.datetime(2023, 6, 7, 20, 0, 0, 123) + access_token.expires_at, + datetime.datetime(2023, 6, 7, 20, 0, 0, 123, tzinfo=datetime.timezone.utc), ) self.assertEqual(access_token.refresh_token, "refresh_token_removed") self.assertEqual(access_token.refresh_expires_in, 15811200) self.assertEqual( access_token.refresh_expires_at, - datetime.datetime(2023, 12, 7, 12, 0, 0, 123), + datetime.datetime(2023, 12, 7, 12, 0, 0, 123, tzinfo=datetime.timezone.utc), ) def testRefreshAccessToken(self): @@ -115,8 +118,10 @@ class ApplicationOAuth(Framework.TestCase): ) with mock.patch("github.AccessToken.datetime") as dt: - dt.utcnow = mock.Mock( - return_value=datetime.datetime(2023, 6, 7, 12, 0, 0, 123) + dt.now = mock.Mock( + return_value=datetime.datetime( + 2023, 6, 7, 12, 0, 0, 123, tzinfo=datetime.timezone.utc + ) ) refreshed = self.app.refresh_access_token(access_token.refresh_token) @@ -133,17 +138,19 @@ class ApplicationOAuth(Framework.TestCase): self.assertEqual(refreshed.type, "bearer") self.assertEqual(refreshed.scope, "") self.assertEqual( - refreshed.created, datetime.datetime(2023, 6, 7, 12, 0, 0, 123) + refreshed.created, + datetime.datetime(2023, 6, 7, 12, 0, 0, 123, tzinfo=datetime.timezone.utc), ) self.assertEqual(refreshed.expires_in, 28800) self.assertEqual( - refreshed.expires_at, datetime.datetime(2023, 6, 7, 20, 0, 0, 123) + refreshed.expires_at, + datetime.datetime(2023, 6, 7, 20, 0, 0, 123, tzinfo=datetime.timezone.utc), ) self.assertEqual(refreshed.refresh_token, "another_refresh_token_removed") self.assertEqual(refreshed.refresh_expires_in, 15811200) self.assertEqual( refreshed.refresh_expires_at, - datetime.datetime(2023, 12, 7, 12, 0, 0, 123), + datetime.datetime(2023, 12, 7, 12, 0, 0, 123, tzinfo=datetime.timezone.utc), ) def testGetAccessTokenBadCode(self): diff --git a/tests/Authentication.py b/tests/Authentication.py index 3c511f26..fb7b9704 100644 --- a/tests/Authentication.py +++ b/tests/Authentication.py @@ -26,7 +26,6 @@ # # ################################################################################ import datetime -import warnings from unittest import mock import jwt @@ -42,16 +41,6 @@ class Authentication(Framework.BasicTestCase): g = github.Github() self.assertEqual(g.get_user("jacquev6").name, "Vincent Jacques") - def assertWarning(self, warning, expected): - self.assertWarnings(warning, expected) - - def assertWarnings(self, warning, *expecteds): - self.assertEqual(len(warning.warnings), len(expecteds)) - for message, expected in zip(warning.warnings, expecteds): - self.assertIsInstance(message, warnings.WarningMessage) - self.assertIsInstance(message.message, DeprecationWarning) - self.assertEqual(message.message.args, (expected,)) - def testBasicAuthentication(self): with self.assertWarns(DeprecationWarning) as warning: g = github.Github(self.login.login, self.login.password) @@ -124,25 +113,33 @@ class Authentication(Framework.BasicTestCase): g = github.Github() app = g.get_oauth_application(client_id, client_secret) with mock.patch("github.AccessToken.datetime") as dt: - dt.utcnow = mock.Mock( - return_value=datetime.datetime(2023, 6, 7, 12, 0, 0, 123) + dt.now = mock.Mock( + return_value=datetime.datetime( + 2023, 6, 7, 12, 0, 0, 123, tzinfo=datetime.timezone.utc + ) ) token = app.refresh_access_token(refresh_token) self.assertEqual(token.token, "fresh access token") self.assertEqual(token.type, "bearer") self.assertEqual(token.scope, "") self.assertEqual(token.expires_in, 28800) - self.assertEqual(token.expires_at, datetime.datetime(2023, 6, 7, 20, 0, 0, 123)) + self.assertEqual( + token.expires_at, + datetime.datetime(2023, 6, 7, 20, 0, 0, 123, tzinfo=datetime.timezone.utc), + ) self.assertEqual(token.refresh_token, "fresh refresh token") self.assertEqual(token.refresh_expires_in, 15811200) self.assertEqual( - token.refresh_expires_at, datetime.datetime(2023, 12, 7, 12, 0, 0, 123) + token.refresh_expires_at, + datetime.datetime(2023, 12, 7, 12, 0, 0, 123, tzinfo=datetime.timezone.utc), ) auth = app.get_app_user_auth(token) with mock.patch("github.Auth.datetime") as dt: - dt.utcnow = mock.Mock( - return_value=datetime.datetime(2023, 6, 7, 20, 0, 0, 123) + dt.now = mock.Mock( + return_value=datetime.datetime( + 2023, 6, 7, 20, 0, 0, 123, tzinfo=datetime.timezone.utc + ) ) self.assertEqual(auth._is_expired, False) self.assertEqual(auth.token, "fresh access token") @@ -151,8 +148,10 @@ class Authentication(Framework.BasicTestCase): # expire auth token with mock.patch("github.Auth.datetime") as dt: - dt.utcnow = mock.Mock( - return_value=datetime.datetime(2023, 6, 7, 20, 0, 1, 123) + dt.now = mock.Mock( + return_value=datetime.datetime( + 2023, 6, 7, 20, 0, 1, 123, tzinfo=datetime.timezone.utc + ) ) self.assertEqual(auth._is_expired, True) self.assertEqual(auth.token, "another access token") diff --git a/tests/Framework.py b/tests/Framework.py index 37578885..a9b150e0 100644 --- a/tests/Framework.py +++ b/tests/Framework.py @@ -39,6 +39,7 @@ import json import os import traceback import unittest +import warnings import httpretty # type: ignore from requests.structures import CaseInsensitiveDict @@ -328,6 +329,21 @@ class BasicTestCase(unittest.TestCase): self.__closeReplayFileIfNeeded() github.Requester.Requester.resetConnectionClasses() + def assertWarning(self, warning, expected): + self.assertWarnings(warning, expected) + + def assertWarnings(self, warning, *expecteds): + self.assertEqual(len(warning.warnings), len(expecteds)) + actual = [ + (type(message), type(message.message), message.message.args) + for message in warning.warnings + ] + expected = [ + (warnings.WarningMessage, DeprecationWarning, (expected,)) + for expected in expecteds + ] + self.assertSequenceEqual(actual, expected) + def __openFile(self, mode): for (_, _, functionName, _) in traceback.extract_stack(): if ( diff --git a/tests/GithubApp.py b/tests/GithubApp.py index 56df1d5c..558720ed 100644 --- a/tests/GithubApp.py +++ b/tests/GithubApp.py @@ -22,7 +22,10 @@ from datetime import datetime +import github + from . import Framework +from .GithubIntegration import APP_ID, PRIVATE_KEY class GithubApp(Framework.TestCase): @@ -99,8 +102,23 @@ class GithubApp(Framework.TestCase): self.assertEqual(app.url, "/apps/github-actions") def testGetAuthenticatedApp(self): - # For this to work correctly in record mode, this test must be run with --auth_with_jwt - app = self.g.get_app() + auth = github.Auth.AppAuth(APP_ID, PRIVATE_KEY) + g = github.Github(auth=auth) + + with self.assertWarns(DeprecationWarning) as warning: + # we ignore warnings from httpretty dependency + import warnings + + warnings.filterwarnings("ignore", module="httpretty") + + app = g.get_app() + + self.assertWarning( + warning, + "Argument slug is mandatory, calling this method without the slug argument is deprecated, " + "please use github.GithubIntegration(auth=github.Auth.AppAuth(...)).get_app() instead", + ) + self.assertEqual(app.created_at, datetime(2020, 8, 1, 17, 23, 46)) self.assertEqual(app.description, "Sample App to test PyGithub") self.assertListEqual( diff --git a/tests/GithubIntegration.py b/tests/GithubIntegration.py index 68f57fc0..ab378b40 100644 --- a/tests/GithubIntegration.py +++ b/tests/GithubIntegration.py @@ -1,5 +1,4 @@ import time # NOQA -import warnings import requests # NOQA @@ -42,16 +41,6 @@ class GithubIntegration(Framework.BasicTestCase): self.repo_installation_id = 30614431 self.user_installation_id = 30614431 - def assertWarning(self, warning, expected): - self.assertWarnings(warning, expected) - - def assertWarnings(self, warning, *expecteds): - self.assertEqual(len(warning.warnings), len(expecteds)) - for message, expected in zip(warning.warnings, expecteds): - self.assertIsInstance(message, warnings.WarningMessage) - self.assertIsInstance(message.message, DeprecationWarning) - self.assertEqual(message.message.args, (expected,)) - def testDeprecatedAppAuth(self): # Replay data copied from testGetInstallations to test authentication only with self.assertWarns(DeprecationWarning) as warning: @@ -67,6 +56,16 @@ class GithubIntegration(Framework.BasicTestCase): "instead", ) + def testRequiredAppAuth(self): + # GithubIntegration requires AppAuth authentication. + for auth in [self.oauth_token, self.jwt, self.login]: + with self.assertRaises(AssertionError) as r: + github.GithubIntegration(auth=auth) + self.assertEqual( + str(r.exception), + f"GithubIntegration requires github.Auth.AppAuth authentication, not {type(auth)}", + ) + def testAppAuth(self): # Replay data copied from testDeprecatedAppAuth to test parity auth = github.Auth.AppAuth(APP_ID, PRIVATE_KEY) @@ -227,3 +226,11 @@ class GithubIntegration(Framework.BasicTestCase): ) self.assertEqual(raisedexp.exception.status, 400) + + def testGetApp(self): + auth = github.Auth.AppAuth(APP_ID, PRIVATE_KEY) + github_integration = github.GithubIntegration(auth=auth) + app = github_integration.get_app() + + self.assertEqual(app.name, "PyGithubTest") + self.assertEqual(app.url, "/apps/pygithubtest") diff --git a/tests/ReplayData/GithubApp.testGetAuthenticatedApp.txt b/tests/ReplayData/GithubApp.testGetAuthenticatedApp.txt index a00fa1a1..46d5e4cc 100644 --- a/tests/ReplayData/GithubApp.testGetAuthenticatedApp.txt +++ b/tests/ReplayData/GithubApp.testGetAuthenticatedApp.txt @@ -3,7 +3,7 @@ GET api.github.com None /app -{'Authorization': 'Basic login_and_password_removed', 'User-Agent': 'PyGithub/Python'} +{'Authorization': 'Bearer jwt_removed', 'User-Agent': 'PyGithub/Python', 'Accept': 'application/vnd.github.machine-man-preview+json'} None 200 [('Date', 'Sun, 02 Aug 2020 04:57:48 GMT'), ('Content-Type', 'application/json; charset=utf-8'), ('Transfer-Encoding', 'chunked'), ('Server', 'GitHub.com'), ('Status', '200 OK'), ('Cache-Control', 'public, max-age=60, s-maxage=60'), ('Vary', 'Accept, Accept-Encoding, Accept, X-Requested-With, Accept-Encoding'), ('ETag', 'W/"76244215f77fc6f3d9262dea400b2567"'), ('X-GitHub-Media-Type', 'github.v3; format=json'), ('Access-Control-Expose-Headers', 'ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, Deprecation, Sunset'), ('Access-Control-Allow-Origin', '*'), ('Strict-Transport-Security', 'max-age=31536000; includeSubdomains; preload'), ('X-Frame-Options', 'deny'), ('X-Content-Type-Options', 'nosniff'), ('X-XSS-Protection', '1; mode=block'), ('Referrer-Policy', 'origin-when-cross-origin, strict-origin-when-cross-origin'), ('Content-Security-Policy', "default-src 'none'"), ('Content-Encoding', 'gzip'), ('X-GitHub-Request-Id', 'C28A:25FE:11739F:15A3E5:5F2647CC')] diff --git a/tests/ReplayData/GithubIntegration.testGetApp.txt b/tests/ReplayData/GithubIntegration.testGetApp.txt new file mode 100644 index 00000000..20e3f744 --- /dev/null +++ b/tests/ReplayData/GithubIntegration.testGetApp.txt @@ -0,0 +1,10 @@ +https +GET +api.github.com +None +/app +{'Authorization': 'Bearer jwt_removed', 'User-Agent': 'PyGithub/Python', 'Accept': 'application/vnd.github.machine-man-preview+json'} +None +200 +[('Date', 'Sun, 02 Aug 2020 04:57:48 GMT'), ('Content-Type', 'application/json; charset=utf-8'), ('Transfer-Encoding', 'chunked'), ('Server', 'GitHub.com'), ('Status', '200 OK'), ('Cache-Control', 'public, max-age=60, s-maxage=60'), ('Vary', 'Accept, Accept-Encoding, Accept, X-Requested-With, Accept-Encoding'), ('ETag', 'W/"76244215f77fc6f3d9262dea400b2567"'), ('X-GitHub-Media-Type', 'github.v3; format=json'), ('Access-Control-Expose-Headers', 'ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, Deprecation, Sunset'), ('Access-Control-Allow-Origin', '*'), ('Strict-Transport-Security', 'max-age=31536000; includeSubdomains; preload'), ('X-Frame-Options', 'deny'), ('X-Content-Type-Options', 'nosniff'), ('X-XSS-Protection', '1; mode=block'), ('Referrer-Policy', 'origin-when-cross-origin, strict-origin-when-cross-origin'), ('Content-Security-Policy', "default-src 'none'"), ('Content-Encoding', 'gzip'), ('X-GitHub-Request-Id', 'C28A:25FE:11739F:15A3E5:5F2647CC')] +{"id":75269,"slug":"pygithubtest","node_id":"MDM6QXBwNzUyNjk=","owner":{"login":"wrecker","id":4432114,"node_id":"MDQ6VXNlcjQ0MzIxMTQ=","avatar_url":"https://avatars2.githubusercontent.com/u/4432114?v=4","gravatar_id":"","url":"https://api.github.com/users/wrecker","html_url":"https://github.com/wrecker","followers_url":"https://api.github.com/users/wrecker/followers","following_url":"https://api.github.com/users/wrecker/following{/other_user}","gists_url":"https://api.github.com/users/wrecker/gists{/gist_id}","starred_url":"https://api.github.com/users/wrecker/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/wrecker/subscriptions","organizations_url":"https://api.github.com/users/wrecker/orgs","repos_url":"https://api.github.com/users/wrecker/repos","events_url":"https://api.github.com/users/wrecker/events{/privacy}","received_events_url":"https://api.github.com/users/wrecker/received_events","type":"User","site_admin":false},"name":"PyGithubTest","description":"Sample App to test PyGithub","external_url":"https://pygithub.readthedocs.io","html_url":"https://github.com/apps/pygithubtest","created_at":"2020-08-01T17:23:46Z","updated_at":"2020-08-01T17:44:31Z","permissions":{"actions":"write","checks":"write","keys":"read","members":"read","metadata":"read","packages":"read","pages":"read","repository_hooks":"write","vulnerability_alerts":"read","workflows":"write"},"events":["check_run","check_suite","label","member","public"],"installations_count":1} \ No newline at end of file