Skip to content

Commit 29359f3

Browse files
EnricoMitiangolo
andauthored
Add RepositoryDiscussion powered by GraphQL API (PyGithub#3048)
Adds `RepositoryDiscussion`, `RepositoryDiscussionComment` and `RepositoryDiscussionCategory` as pure GraphQL API classes. Retrieved GraphQL data with user-defined schema can be used to populate those GraphQL objects and inner REST API objects (all other existing PyGithub classes). Fixes PyGithub#2283. --------- Co-authored-by: Sebastián Ramírez <[email protected]>
1 parent cd30e37 commit 29359f3

34 files changed

+2045
-85
lines changed

github/DiscussionBase.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
############################ Copyrights and license ############################
2+
# #
3+
# Copyright 2024 Enrico Minack <[email protected]> #
4+
# #
5+
# This file is part of PyGithub. #
6+
# http://pygithub.readthedocs.io/ #
7+
# #
8+
# PyGithub is free software: you can redistribute it and/or modify it under #
9+
# the terms of the GNU Lesser General Public License as published by the Free #
10+
# Software Foundation, either version 3 of the License, or (at your option) #
11+
# any later version. #
12+
# #
13+
# PyGithub is distributed in the hope that it will be useful, but WITHOUT ANY #
14+
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS #
15+
# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more #
16+
# details. #
17+
# #
18+
# You should have received a copy of the GNU Lesser General Public License #
19+
# along with PyGithub. If not, see <http://www.gnu.org/licenses/>. #
20+
# #
21+
################################################################################
22+
23+
from __future__ import annotations
24+
25+
from datetime import datetime
26+
from typing import Any
27+
28+
import github.GithubObject
29+
import github.NamedUser
30+
from github.GithubObject import Attribute, CompletableGithubObject, NotSet
31+
32+
33+
class DiscussionBase(CompletableGithubObject):
34+
"""
35+
This class represents a the shared attributes between RepositoryDiscussion and TeamDiscussion
36+
https://docs.github.com/en/graphql/reference/objects#discussion
37+
https://docs.github.com/en/rest/reference/teams#discussions
38+
"""
39+
40+
def _initAttributes(self) -> None:
41+
self._author: Attribute[github.NamedUser.NamedUser | None] = NotSet
42+
self._body: Attribute[str] = NotSet
43+
self._body_html: Attribute[str] = NotSet
44+
self._created_at: Attribute[datetime] = NotSet
45+
self._last_edited_at: Attribute[datetime] = NotSet
46+
self._number: Attribute[int] = NotSet
47+
self._title: Attribute[str] = NotSet
48+
self._updated_at: Attribute[datetime] = NotSet
49+
self._url: Attribute[str] = NotSet
50+
51+
def __repr__(self) -> str:
52+
return self.get__repr__({"number": self._number.value, "title": self._title.value})
53+
54+
@property
55+
def author(self) -> github.NamedUser.NamedUser | None:
56+
self._completeIfNotSet(self._author)
57+
return self._author.value
58+
59+
@property
60+
def body(self) -> str:
61+
self._completeIfNotSet(self._body)
62+
return self._body.value
63+
64+
@property
65+
def body_html(self) -> str:
66+
self._completeIfNotSet(self._body_html)
67+
return self._body_html.value
68+
69+
@property
70+
def created_at(self) -> datetime:
71+
self._completeIfNotSet(self._created_at)
72+
return self._created_at.value
73+
74+
@property
75+
def last_edited_at(self) -> datetime:
76+
self._completeIfNotSet(self._last_edited_at)
77+
return self._last_edited_at.value
78+
79+
@property
80+
def number(self) -> int:
81+
self._completeIfNotSet(self._number)
82+
return self._number.value
83+
84+
@property
85+
def title(self) -> str:
86+
self._completeIfNotSet(self._title)
87+
return self._title.value
88+
89+
@property
90+
def updated_at(self) -> datetime:
91+
self._completeIfNotSet(self._updated_at)
92+
return self._updated_at.value
93+
94+
@property
95+
def url(self) -> str:
96+
self._completeIfNotSet(self._url)
97+
return self._url.value
98+
99+
def _useAttributes(self, attributes: dict[str, Any]) -> None:
100+
if "author" in attributes: # pragma no branch
101+
self._author = self._makeClassAttribute(github.NamedUser.NamedUser, attributes["author"])
102+
if "body" in attributes: # pragma no branch
103+
self._body = self._makeStringAttribute(attributes["body"])
104+
if "body_html" in attributes: # pragma no branch
105+
self._body_html = self._makeStringAttribute(attributes["body_html"])
106+
if "created_at" in attributes: # pragma no branch
107+
self._created_at = self._makeDatetimeAttribute(attributes["created_at"])
108+
if "last_edited_at" in attributes: # pragma no branch
109+
self._last_edited_at = self._makeDatetimeAttribute(attributes["last_edited_at"])
110+
if "number" in attributes: # pragma no branch
111+
self._number = self._makeIntAttribute(attributes["number"])
112+
if "title" in attributes:
113+
self._title = self._makeStringAttribute(attributes["title"])
114+
if "updated_at" in attributes: # pragma no branch
115+
self._updated_at = self._makeDatetimeAttribute(attributes["updated_at"])
116+
if "url" in attributes: # pragma no branch
117+
self._url = self._makeStringAttribute(attributes["url"])

github/DiscussionCommentBase.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
############################ Copyrights and license ############################
2+
# #
3+
# Copyright 2024 Enrico Minack <[email protected]> #
4+
# #
5+
# This file is part of PyGithub. #
6+
# http://pygithub.readthedocs.io/ #
7+
# #
8+
# PyGithub is free software: you can redistribute it and/or modify it under #
9+
# the terms of the GNU Lesser General Public License as published by the Free #
10+
# Software Foundation, either version 3 of the License, or (at your option) #
11+
# any later version. #
12+
# #
13+
# PyGithub is distributed in the hope that it will be useful, but WITHOUT ANY #
14+
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS #
15+
# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more #
16+
# details. #
17+
# #
18+
# You should have received a copy of the GNU Lesser General Public License #
19+
# along with PyGithub. If not, see <http://www.gnu.org/licenses/>. #
20+
# #
21+
################################################################################
22+
23+
from __future__ import annotations
24+
25+
from datetime import datetime
26+
from typing import Any
27+
28+
import github.GithubObject
29+
import github.NamedUser
30+
from github.GithubObject import Attribute, CompletableGithubObject, NotSet
31+
32+
33+
class DiscussionCommentBase(CompletableGithubObject):
34+
"""
35+
This class represents a the shared attributes between RepositoryDiscussionComment and TeamDiscussionComment
36+
https://docs.github.com/en/graphql/reference/objects#discussioncomment
37+
https://docs.github.com/de/rest/teams/discussion-comments
38+
"""
39+
40+
def _initAttributes(self) -> None:
41+
self._author: Attribute[github.NamedUser.NamedUser | None] = NotSet
42+
self._body: Attribute[str] = NotSet
43+
self._body_html: Attribute[str] = NotSet
44+
self._created_at: Attribute[datetime] = NotSet
45+
self._html_url: Attribute[str] = NotSet
46+
self._last_edited_at: Attribute[datetime] = NotSet
47+
self._node_id: Attribute[str] = NotSet
48+
self._updated_at: Attribute[datetime] = NotSet
49+
self._url: Attribute[str] = NotSet
50+
51+
def __repr__(self) -> str:
52+
return self.get__repr__({"node_id": self._node_id.value})
53+
54+
@property
55+
def author(self) -> github.NamedUser.NamedUser | None:
56+
self._completeIfNotSet(self._author)
57+
return self._author.value
58+
59+
@property
60+
def body(self) -> str:
61+
self._completeIfNotSet(self._body)
62+
return self._body.value
63+
64+
@property
65+
def body_html(self) -> str:
66+
self._completeIfNotSet(self._body_html)
67+
return self._body_html.value
68+
69+
@property
70+
def created_at(self) -> datetime:
71+
self._completeIfNotSet(self._created_at)
72+
return self._created_at.value
73+
74+
@property
75+
def html_url(self) -> str:
76+
self._completeIfNotSet(self._html_url)
77+
return self._html_url.value
78+
79+
@property
80+
def last_edited_at(self) -> datetime:
81+
self._completeIfNotSet(self._last_edited_at)
82+
return self._last_edited_at.value
83+
84+
@property
85+
def node_id(self) -> str:
86+
self._completeIfNotSet(self._node_id)
87+
return self._node_id.value
88+
89+
@property
90+
def updated_at(self) -> datetime:
91+
self._completeIfNotSet(self._updated_at)
92+
return self._updated_at.value
93+
94+
@property
95+
def url(self) -> str:
96+
self._completeIfNotSet(self._url)
97+
return self._url.value
98+
99+
def _useAttributes(self, attributes: dict[str, Any]) -> None:
100+
if "author" in attributes: # pragma no branch
101+
self._author = self._makeClassAttribute(github.NamedUser.NamedUser, attributes["author"])
102+
if "body" in attributes: # pragma no branch
103+
self._body = self._makeStringAttribute(attributes["body"])
104+
if "body_html" in attributes: # pragma no branch
105+
self._body_html = self._makeStringAttribute(attributes["body_html"])
106+
if "created_at" in attributes: # pragma no branch
107+
self._created_at = self._makeDatetimeAttribute(attributes["created_at"])
108+
if "html_url" in attributes: # pragma no branch
109+
self._html_url = self._makeStringAttribute(attributes["html_url"])
110+
if "last_edited_at" in attributes: # pragma no branch
111+
self._last_edited_at = self._makeDatetimeAttribute(attributes["last_edited_at"])
112+
if "node_id" in attributes: # pragma no branch
113+
self._node_id = self._makeStringAttribute(attributes["node_id"])
114+
if "updated_at" in attributes: # pragma no branch
115+
self._updated_at = self._makeDatetimeAttribute(attributes["updated_at"])
116+
if "url" in attributes: # pragma no branch
117+
self._url = self._makeStringAttribute(attributes["url"])

github/GithubIntegration.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,8 +255,10 @@ def get_installation(self, owner: str, repo: str) -> Installation:
255255
"""
256256
Deprecated by get_repo_installation.
257257
258-
:calls: `GET /repos/{owner}/{repo}/installation
259-
<https://docs.github.com/en/rest/reference/apps#get-a-repository-installation-for-the-authenticated-app>`
258+
:calls:`GET /repos/{owner}/{repo}/installation <https://docs.github.com/en/rest/reference/apps#get-a-repository-
259+
installation-for-the-authenticated-app>`
260+
:calls:`GET /repos/{owner}/{repo}/installation <https://docs.github.com/en/rest/reference/apps#get-a-repository-
261+
installation-for-the-authenticated-app>`
260262
261263
"""
262264
owner = urllib.parse.quote(owner)

github/GithubObject.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,12 @@
4848
################################################################################
4949

5050
import email.utils
51+
import re
5152
import typing
5253
from datetime import datetime, timezone
5354
from decimal import Decimal
5455
from operator import itemgetter
55-
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Type, Union
56+
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Type, Union, overload
5657

5758
from typing_extensions import Protocol, TypeGuard
5859

@@ -139,6 +140,62 @@ def is_optional_list(v: Any, type: Union[Type, Tuple[Type, ...]]) -> bool:
139140
return isinstance(v, _NotSetType) or isinstance(v, list) and all(isinstance(element, type) for element in v)
140141

141142

143+
camel_to_snake_case_regexp = re.compile(r"(?<!^)(?=[A-Z])")
144+
145+
146+
@overload
147+
def as_rest_api_attributes(graphql_attributes: Dict[str, Any]) -> Dict[str, Any]:
148+
...
149+
150+
151+
@overload
152+
def as_rest_api_attributes(graphql_attributes: None) -> None:
153+
...
154+
155+
156+
def as_rest_api_attributes(graphql_attributes: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
157+
"""
158+
Converts attributes from GraphQL schema to REST API schema.
159+
160+
The GraphQL API uses lower camel case (e.g. createdAt), whereas REST API uses snake case (created_at). Initializing
161+
REST API GithubObjects from GraphQL API attributes requires transformation provided by this method.
162+
163+
Further renames GraphQL attributes to REST API attributes where the case conversion is not sufficient. For example,
164+
GraphQL attribute 'id' is equivalent to REST API attribute 'node_id'.
165+
166+
"""
167+
if graphql_attributes is None:
168+
return None
169+
170+
attribute_translation = {
171+
"id": "node_id",
172+
"databaseId": "id", # must be after 'id': 'node_id'!
173+
"url": "html_url",
174+
}
175+
176+
def translate(attr: str) -> str:
177+
def un_capitalize(match: re.Match) -> str:
178+
return match.group(1) + match.group(2).lower()
179+
180+
attr = attribute_translation.get(attr, attr)
181+
attr = re.sub(r"([A-Z])([A-Z]+)", un_capitalize, attr)
182+
attr = camel_to_snake_case_regexp.sub("_", attr)
183+
attr = attr.lower()
184+
185+
return attr
186+
187+
return {
188+
translate(k): as_rest_api_attributes(v)
189+
if isinstance(v, dict)
190+
else (as_rest_api_attributes_list(v) if isinstance(v, list) else v)
191+
for k, v in graphql_attributes.items()
192+
}
193+
194+
195+
def as_rest_api_attributes_list(graphql_attributes: List[Optional[Dict[str, Any]]]) -> List[Optional[Dict[str, Any]]]:
196+
return [as_rest_api_attributes(v) if isinstance(v, dict) else v for v in graphql_attributes]
197+
198+
142199
class _ValuedAttribute(Attribute[T]):
143200
def __init__(self, value: T):
144201
self._value = value
@@ -172,6 +229,14 @@ class GithubObject:
172229
CHECK_AFTER_INIT_FLAG = False
173230
_url: Attribute[str]
174231

232+
@classmethod
233+
def is_rest(cls) -> bool:
234+
return not cls.is_graphql()
235+
236+
@classmethod
237+
def is_graphql(cls) -> bool:
238+
return False
239+
175240
@classmethod
176241
def setCheckAfterInitFlag(cls, flag: bool) -> None:
177242
cls.CHECK_AFTER_INIT_FLAG = flag
@@ -397,6 +462,12 @@ def _completeIfNeeded(self) -> None:
397462
raise NotImplementedError("BUG: Not Implemented _completeIfNeeded")
398463

399464

465+
class GraphQlObject:
466+
@classmethod
467+
def is_graphql(cls) -> bool:
468+
return True
469+
470+
400471
class NonCompletableGithubObject(GithubObject):
401472
def _completeIfNeeded(self) -> None:
402473
pass

github/MainClass.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@
143143
from github.Project import Project
144144
from github.ProjectColumn import ProjectColumn
145145
from github.Repository import Repository
146+
from github.RepositoryDiscussion import RepositoryDiscussion
146147
from github.Topic import Topic
147148

148149
TGithubObject = TypeVar("TGithubObject", bound=GithubObject)
@@ -326,8 +327,7 @@ def get_rate_limit(self) -> RateLimit:
326327
"""
327328
Rate limit status for different resources (core/search/graphql).
328329
329-
:calls: `GET /rate_limit
330-
<https://docs.github.com/en/rest/reference/rate-limit>`_
330+
:calls:`GET /rate_limit <https://docs.github.com/en/rest/reference/rate-limit>`_
331331
332332
"""
333333
headers, data = self.__requester.requestJsonAndCheck("GET", "/rate_limit")
@@ -468,6 +468,11 @@ def get_repos(
468468
url_parameters,
469469
)
470470

471+
def get_repository_discussion(self, node_id: str, discussion_graphql_schema: str) -> RepositoryDiscussion:
472+
return self.__requester.graphql_node_class(
473+
node_id, discussion_graphql_schema, github.RepositoryDiscussion.RepositoryDiscussion, "Discussion"
474+
)
475+
471476
def get_project(self, id: int) -> Project:
472477
"""
473478
:calls: `GET /projects/{project_id} <https://docs.github.com/en/rest/reference/projects#get-a-project>`_

0 commit comments

Comments
 (0)