diff --git a/src/github3/users.py b/src/github3/users.py index 00c05865..c7bdee84 100644 --- a/src/github3/users.py +++ b/src/github3/users.py @@ -307,6 +307,11 @@ class _User(models.GitHubCore): class_name = "_User" + def __init__(self, json, session): + if json is None: + json = _ghost_json + super().__init__(json, session) + def _update_attributes(self, user): self.avatar_url = user["avatar_url"] self.events_urlt = URITemplate(user["events_url"]) @@ -869,7 +874,7 @@ class AuthenticatedUser(User): """Object to represent the currently authenticated user. This is returned by :meth:`~github3.github.GitHub.me`. It contains the - extra informtation that is not returned for other users such as the + extra information that is not returned for other users such as the currently authenticated user's plan and private email information. .. versionadded:: 1.0.0 @@ -973,3 +978,42 @@ def _update_attributes(self, contributor): UserLike = t.Union[ ShortUser, User, AuthenticatedUser, Collaborator, Contributor, str ] + +_ghost_json: t.Final[t.Dict[str, t.Any]] = { + # from https://api.github.com/users/ghost + "login": "ghost", + "id": 10137, + "node_id": "MDQ6VXNlcjEwMTM3", + "avatar_url": "https://avatars.githubusercontent.com/u/10137?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/ghost", + "html_url": "https://github.com/ghost", + "followers_url": "https://api.github.com/users/ghost/followers", + "following_url": "https://api.github.com/users/ghost/following{/other_user" + "}", + "gists_url": "https://api.github.com/users/ghost/gists{/gist_id}", + "starred_url": "https://api.github.com/users/ghost/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/ghost/subscriptions", + "organizations_url": "https://api.github.com/users/ghost/orgs", + "repos_url": "https://api.github.com/users/ghost/repos", + "events_url": "https://api.github.com/users/ghost/events{/privacy}", + "received_events_url": "https://api.github.com/users/ghost/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": False, + "name": "Deleted user", + "company": None, + "blog": "", + "location": "Nothing to see here, move along.", + "email": None, + "hireable": None, + "bio": "Hi, I'm @ghost! I take the place of user accounts that have been " + "deleted.\n:ghost:\n", + "twitter_username": None, + "public_repos": 0, + "public_gists": 0, + "followers": 11584, + "following": 0, + "created_at": "2008-05-13T06:14:25Z", + "updated_at": "2018-04-10T17:22:33Z", +} diff --git a/tests/cassettes/GitHub_release_author_null.json b/tests/cassettes/GitHub_release_author_null.json new file mode 100644 index 00000000..1c4afb45 --- /dev/null +++ b/tests/cassettes/GitHub_release_author_null.json @@ -0,0 +1 @@ +{"http_interactions": [{"request": {"body": {"encoding": "utf-8", "string": ""}, "headers": {"User-Agent": ["github3.py/4.0.1"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["application/vnd.github.v3.full+json"], "Connection": ["keep-alive"], "Accept-Charset": ["utf-8"], "Content-Type": ["application/json"]}, "method": "GET", "uri": "https://api.github.com/repos/qiskit-community/qiskit-qec"}, "response": {"body": {"encoding": "utf-8", "base64_string": "H4sIAAAAAAAAA+2ZbW/bNhDHv0qgt4sjW42bxkDRDWhXbFibdUixIsMgUBJtMZZEhaTs2kK++/4nSpbkArEb+c2AAkEeaN6Pp+Pd6e5SOiJyZi+uJpPx1fX15bmTyYj7tOZ8ePtufZP8noTvr7fsy1+rMFtuP27fff3wNpze3H567WAzSzl2Pgi9FGb0wEOszYsk8fsfhDJNi0yYjdvbmSuxYgaAOUs0P3fkOuPKmZVOIhcia7k7cdBJs6nnTSeXV/vKbm6W15s779eCfcnj6H2yCu4/bz7cf/768W04hijDYUz5hUqAjo3J9cx17aK+WAgTF0GhuQplZnhmLnCqW7jNWW9Wry/BWKiaUlkIC3u0XNQkKw6cbp65+xSxSZM9RawClVhtpK7AXCaJXIO2r/4xB7o7abqeiiSyxQASpEtXmpjDmnjERzKM0OZ5ylWSpUs/4HfE0rgmxaNnKVjLQj1ypsfSVTyXFbQIdKhEboTMnqdojwCiVAuWiS17PhEEDRCp+DyVKkkQ+Aoe+zyEFS3dKhjDDZlK8ZCLFS5gAHaPAarZ5JQrbjpWwyr5j78SfO3Xn+dFkAjKI1oY7rMopUxQJYjHc0Tt98RNP9tEfHf/UONTlbLOHgqWmSI940pJdRZK+F1I93k2V0hta6mW0GROP5ok9WTAV/fxTfz29SDagas6iEFEAwLVlnwzmEWM0sX3OvxCZAgWSMWMPJRvDivag5Vu909yNcNZOvgBKghgsZTDLVtBABNaF/yoCDhshIql3SbUsiINbNY8JsAO4y0FOjOtxSLjfLBFd6DSbRJ8oFgWxsPRDad07W+VF7DFYJWJAVSQyGAwC+9itwKVro6Zfb0Z/xRaEpk4PbDi85OoTJwd2KgT+EGlLoF2WLxfDVxisL4Nxy1rCycsWxRsMZy8A8EbqBpYsO3BuulwjLUkYKlEVCIoTpMgWxZpbMsU5IfhJm5RLbiqf56urI4wRqeWqsyRpuJQ+XGYWmN6oXEiNPnxPp7+Plw1Hac2cUq3zev25VGfMNTa9duj0bd7Tt2rDHaVhuOWP+XMxJTxcFzOFB+qfI1xy4Chgru4uChjzqoqP+XqBNFuKcAxFcYoWofqWzYcVFgpM1UDMSd1IzQUiWTRYFvvQIDaqx2qs6V0/SJHDz5Y0QrSpaYi4drIbHiObkldfiaNmIvwmIbqcFj2YOUbLbKQn7MkOYdXGxEK+DnaWLpZFLt8uLUsBY+DvsE2UQmHyw++BcUtp3RtcxzxPJGbk2SuDooCXnEMYyKfGbRH3tibjMbTkffydvJyNn0x817dYU+RR70909H41cjzbidXs+kEX7QnL3TcwWDLZORd3U7GM+8FBk20Bem49nn8hiHME7OPTu9EkxUIax23wj+3orP9scm3omEC592Ltu87e7X/7jxOHGrHMuU56pvO7Glf4WZ0JGRHdZceWWwh542vvTGGXp2iJpRFhuua0PKaGdTnKBu6i00xhFP/3JhYZqQJ075NGc7MqAKDN1rJlbxH76u7a22q6mxci6XoCVLR1pMSOizQiWDO0izbnrdW7HKC5C+o4bZXkSHP7PI3xmr1NDASmgUJbxdkzrNa8eYZvSuErwh5pmGfkrphPCjLGQwx8i5o6FcPIn+p1s7+sHvP7Gc6j77aUaf9uBZ5ssOvT9Nu/5TO0DR8efs+ub/7e7q9u/3ttYOpBbKOXPtkA2ScxiRC+4anedIdgK55gCejYsqnRk7O577iD4XAMGxnFiNzEcKw/zjdEUU9xxhVc4xRO8cg5ynUnIUcixF3/j13VkKLQCSYxsJWu2mLHSjM6G46dobTwcKNZ9WOFvE5KxLj2/4NkJRhRkO9fJr7NsSMXHKMbezNwhmMTMnBco7US85Swird8dmPoe83QyMY9MfQF1F2xEDd/TH0bf+fcZTB/ldD34wbGsQ2OZ9SVLdLrt8q14//Afb+uGbLGgAA", "string": ""}, "headers": {"Date": ["Tue, 09 Sep 2025 21:35:05 GMT"], "Content-Type": ["application/json; charset=utf-8"], "Cache-Control": ["public, max-age=60, s-maxage=60"], "Vary": ["Accept,Accept-Encoding, Accept, X-Requested-With"], "ETag": ["W/\"11595d7f6763d9af4e2c76d5b1d24e559ac64c96a3efd52b22d5e1cef13dab0f\""], "Last-Modified": ["Fri, 22 Aug 2025 17:51:51 GMT"], "X-GitHub-Media-Type": ["github.v3; param=full; format=json"], "x-github-api-version-selected": ["2022-11-28"], "Access-Control-Expose-Headers": ["ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, 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": ["0"], "Referrer-Policy": ["origin-when-cross-origin, strict-origin-when-cross-origin"], "Content-Security-Policy": ["default-src 'none'"], "Content-Encoding": ["gzip"], "Server": ["github.com"], "Accept-Ranges": ["bytes"], "X-RateLimit-Limit": ["60"], "X-RateLimit-Remaining": ["58"], "X-RateLimit-Reset": ["1757455665"], "X-RateLimit-Resource": ["core"], "X-RateLimit-Used": ["2"], "Content-Length": ["1473"], "X-GitHub-Request-Id": ["2063:3236F0:947458:84448C:68C09D89"]}, "status": {"code": 200, "message": "OK"}, "url": "https://api.github.com/repos/qiskit-community/qiskit-qec"}, "recorded_at": "2025-09-09T21:35:05"}, {"request": {"body": {"encoding": "utf-8", "string": ""}, "headers": {"User-Agent": ["github3.py/4.0.1"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["application/vnd.github.v3.full+json"], "Connection": ["keep-alive"], "Accept-Charset": ["utf-8"], "Content-Type": ["application/json"]}, "method": "GET", "uri": "https://api.github.com/repos/qiskit-community/qiskit-qec/releases/63525446"}, "response": {"body": {"encoding": "utf-8", "base64_string": "H4sIAAAAAAAAA61TwW7bMAz9FUHnOEpTJ8CEILt067DLgGLYYcPg0pYaC5UtVaK7ZUX/fZSrFIWxoVlX2Cfq8fHhPfKOD8FyyVtEH6UQ4M18Z7Ad6nnjOhG0d1HcmHhtsKBCN/QG94fCjW4IYTVEHcX6dLVcleWazzjEqDFWr80sHnhpwOCtAzUZ8FCM/yk/D7l720OnZxZqbe9pYoudncx7YtNRBiHsxO1iTl8B1rdArEZxefCNbBuwdYHLfrB2xnundJUA/OJddf3j7NP7du26WJ7tv3w8p16iq5JGAkxYEcJOY5XiMmhimxA6RON60++oM3dl0RRikZ/pTQW4Qi6vwEZN+ihwhNrSkFzxQefEucQwEKYJGlCrCqiNLxfLZbEo6f98cirLN3J18nXMS/0Ns1rIcp0wfqgtif0z0yMqr4D89j05EGqw01j+eYMzzTSbX8a/BnummbLXTu2rtFRk2sZvL8YronjY0yxnzAfTQTB2z6Jjm4ZWYnu41Lx+pu7GSz0P0OjiAwSvg6AiXavSNV1mkW54I8Ze1kDPyGVk2JrIILJbsEaxnP9G+C0lMWpD/TMFepSwF0l6RkvWcayEyxdpuHxOxP1vqebu0yAFAAA=", "string": ""}, "headers": {"Date": ["Tue, 09 Sep 2025 21:35:05 GMT"], "Content-Type": ["application/json; charset=utf-8"], "Cache-Control": ["public, max-age=60, s-maxage=60"], "Vary": ["Accept,Accept-Encoding, Accept, X-Requested-With"], "ETag": ["W/\"5e5605ebcad750bc7a39665523e25a157ef102e158e52019afe3f658db409de5\""], "Last-Modified": ["Mon, 04 Apr 2022 13:50:46 GMT"], "X-GitHub-Media-Type": ["github.v3; param=full; format=json"], "x-github-api-version-selected": ["2022-11-28"], "Access-Control-Expose-Headers": ["ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, 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": ["0"], "Referrer-Policy": ["origin-when-cross-origin, strict-origin-when-cross-origin"], "Content-Security-Policy": ["default-src 'none'"], "Content-Encoding": ["gzip"], "Server": ["github.com"], "Accept-Ranges": ["bytes"], "X-RateLimit-Limit": ["60"], "X-RateLimit-Remaining": ["57"], "X-RateLimit-Reset": ["1757455665"], "X-RateLimit-Resource": ["core"], "X-RateLimit-Used": ["3"], "Content-Length": ["467"], "X-GitHub-Request-Id": ["2063:3236F0:94752F:84455F:68C09D89"]}, "status": {"code": 200, "message": "OK"}, "url": "https://api.github.com/repos/qiskit-community/qiskit-qec/releases/63525446"}, "recorded_at": "2025-09-09T21:35:05"}], "recorded_with": "betamax/0.9.0"} diff --git a/tests/integration/test_github.py b/tests/integration/test_github.py index 9a992ee2..eba1d965 100644 --- a/tests/integration/test_github.py +++ b/tests/integration/test_github.py @@ -774,6 +774,17 @@ def test_user(self): " encoded" ) + def test_release_by_ghostuser(self): + """Test the ability to retrieve a release with "author: null" (ghost user).""" + cassette_name = self.cassette_name("release_author_null") + with self.recorder.use_cassette(cassette_name): + repository = self.gh.repository("qiskit-community", "qiskit-qec") + release = repository.release(63525446) + + assert isinstance(release, github3.repos.release.Release) + assert isinstance(release.author, github3.users.ShortUser) + assert release.author.login == "ghost" + def test_unfollow(self): """Test the ability to unfollow a user.""" self.token_login() diff --git a/tests/unit/test_users.py b/tests/unit/test_users.py index a9de6bb8..88d1d44b 100644 --- a/tests/unit/test_users.py +++ b/tests/unit/test_users.py @@ -67,6 +67,21 @@ def test_is_following(self): ) +class TestGhostUser(helper.UnitHelper): + """Test methods on Ghost User class.""" + + described_class = github3.users.User + example_data = get_users_example_data() + + def after_setup(self): + self.instance = github3.users.User(None, self.session) + + def test_str(self): + """Show that instance string and repr is ghost.""" + assert str(self.instance) == "ghost" + assert repr(self.instance) == "" + + class TestUserGPGKeyRequiresAuth(helper.UnitRequiresAuthenticationHelper): """Unit tests that demonstrate which GPGKey methods require auth."""