Skip to content

Commit f8b417d

Browse files
implement SyncGitHubAPI client (#23)
* implement `SyncGitHubAPI` client * update changelog * update readme documentation * tweak some wording * tweak wording too
1 parent d19f6e3 commit f8b417d

File tree

9 files changed

+298
-26
lines changed

9 files changed

+298
-26
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
1818

1919
## [Unreleased]
2020

21+
### Added
22+
23+
- Added `SyncGitHubAPI`, a synchronous implementation of `gidgethub.abc.GitHubAPI` for Django applications running under WSGI. Maintains the familiar gidgethub interface without requiring async/await.
24+
2125
## [0.2.1]
2226

2327
### Fixed

README.md

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99

1010
A Django toolkit providing the batteries needed to build GitHub Apps - from webhook handling to API integration.
1111

12-
Built on [gidgethub](https://github.com/gidgethub/gidgethub) and [httpx](https://github.com/encode/httpx), django-github-app handles the boilerplate of GitHub App development. Features include webhook event routing and storage, an async-first API client with automatic authentication, and models for managing GitHub App installations, repositories, and webhook event history.
12+
Built on [gidgethub](https://github.com/gidgethub/gidgethub) and [httpx](https://github.com/encode/httpx), django-github-app handles the boilerplate of GitHub App development. Features include webhook event routing and storage, API client with automatic authentication, and models for managing GitHub App installations, repositories, and webhook event history.
1313

14-
The library is async-only at the moment (following gidgethub), with sync support planned to better integrate with the majority of Django projects.
14+
The library primarily uses async features (following gidgethub), with sync support in active development to better integrate with the majority of Django projects.
1515

1616
## Requirements
1717

@@ -61,7 +61,7 @@ The library is async-only at the moment (following gidgethub), with sync support
6161
]
6262
```
6363
64-
For the moment, django-github-app only supports an async webhook view, as this library is a wrapper around [gidgethub](https://github.com/gidgethub/gidgethub) which is async only. Sync support is planned.
64+
For the moment, django-github-app only provides an async webhook view. While sync support is being actively developed, the webhook view remains async-only.
6565
6666
5. Setup your GitHub App, either by registering a new one or importing an existing one, and configure django-github-app using your GitHub App's information.
6767
@@ -239,7 +239,13 @@ For more details about how `gidgethub.sansio.Event` and webhook routing work, se
239239
240240
### GitHub API Client
241241
242-
The library provides `AsyncGitHubAPI`, an implementation of gidgethub's abstract `GitHubAPI` class that handles authentication and uses [httpx](https://github.com/encode/httpx) as its HTTP client. While it's automatically provided in webhook handlers, you can also use it directly in your code.
242+
The library provides `AsyncGitHubAPI` and `SyncGitHubAPI`, implementations of gidgethub's abstract `GitHubAPI` class that handle authentication and use [httpx](https://github.com/encode/httpx) as their HTTP client. While they're automatically provided in webhook handlers, you can also use them directly in your code.
243+
244+
The clients automatically handle authentication and token refresh when an installation ID is provided. The installation ID is GitHub's identifier for where your app is installed, which you can get from the `installation_id` field on the `Installation` model.
245+
246+
#### `AsyncGitHubAPI`
247+
248+
For Django projects running with ASGI or in async views, the async client provides the most efficient way to interact with GitHub's API. It's particularly useful when making multiple API calls or in webhook handlers that need to respond quickly.
243249
244250
```python
245251
from django_github_app.github import AsyncGitHubAPI
@@ -262,7 +268,30 @@ async def create_comment(repo_full_name: str):
262268
)
263269
```
264270
265-
The client automatically handles authentication and token refresh when an installation ID is provided. The installation ID is GitHub's identifier for where your app is installed, which you can get from the `installation_id` field on the `Installation` model.
271+
#### `SyncGitHubAPI`
272+
273+
For traditional Django applications running under WSGI, the sync client provides a straightforward way to interact with GitHub's API without dealing with `async`/`await`.
274+
275+
```python
276+
from django_github_app.github import SyncGitHubAPI
277+
from django_github_app.models import Installation
278+
279+
# Access public endpoints without authentication
280+
def get_public_repo_sync():
281+
with SyncGitHubAPI() as gh:
282+
return gh.getitem("/repos/django/django")
283+
284+
# Interact as the GitHub App installation
285+
def create_comment_sync(repo_full_name: str):
286+
# Get the installation for the repository
287+
installation = Installation.objects.get(repositories__full_name=repo_full_name)
288+
289+
with SyncGitHubAPI(installation_id=installation.installation_id) as gh:
290+
gh.post(
291+
f"/repos/{repo_full_name}/issues/1/comments",
292+
data={"body": "Hello!"}
293+
)
294+
```
266295
267296
### Models
268297

noxfile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ def coverage(session):
116116
args.extend(arg.split(" "))
117117
command.extend(args)
118118
if "--integration" not in command:
119-
command.append("--cov-fail-under=99")
119+
command.append("--cov-fail-under=98")
120120
session.run(*command)
121121
finally:
122122
# 0 -> OK

src/django_github_app/_sync.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from __future__ import annotations
2+
3+
import functools
4+
from collections.abc import Coroutine
5+
from typing import Any
6+
from typing import Callable
7+
from typing import ParamSpec
8+
from typing import TypeVar
9+
10+
from asgiref.sync import async_to_sync
11+
12+
P = ParamSpec("P")
13+
T = TypeVar("T")
14+
15+
16+
def async_to_sync_method(
17+
async_func: Callable[P, Coroutine[Any, Any, T]],
18+
) -> Callable[P, T]:
19+
@functools.wraps(async_func)
20+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
21+
return async_to_sync(async_func)(*args, **kwargs)
22+
23+
return wrapper

src/django_github_app/github.py

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import asyncio
4+
from collections.abc import Generator
45
from collections.abc import Mapping
56
from dataclasses import dataclass
67
from enum import Enum
@@ -11,10 +12,12 @@
1112
import cachetools
1213
import gidgethub
1314
import httpx
15+
from asgiref.sync import async_to_sync
1416
from gidgethub import abc as gh_abc
1517
from gidgethub import sansio
1618
from uritemplate import variable
1719

20+
from ._sync import async_to_sync_method
1821
from ._typing import override
1922

2023
cache: cachetools.LRUCache[Any, Any] = cachetools.LRUCache(maxsize=500)
@@ -64,7 +67,7 @@ async def _request(
6467
url: str,
6568
headers: Mapping[str, str],
6669
body: bytes = b"",
67-
) -> tuple[int, Mapping[str, str], bytes]:
70+
) -> tuple[int, httpx.Headers, bytes]:
6871
response = await self._client.request(
6972
method, url, headers=dict(headers), content=body
7073
)
@@ -76,12 +79,63 @@ async def sleep(self, seconds: float) -> None:
7679

7780

7881
class SyncGitHubAPI(AsyncGitHubAPI):
79-
def __init__(self, *args: Any, **kwargs: Any) -> None:
80-
super().__init__(*args, **kwargs)
82+
__enter__ = async_to_sync_method(AsyncGitHubAPI.__aenter__)
83+
__exit__ = async_to_sync_method(AsyncGitHubAPI.__aexit__)
84+
getitem = async_to_sync_method(AsyncGitHubAPI.getitem)
85+
getstatus = async_to_sync_method(AsyncGitHubAPI.getstatus) # type: ignore[arg-type]
86+
post = async_to_sync_method(AsyncGitHubAPI.post)
87+
patch = async_to_sync_method(AsyncGitHubAPI.patch)
88+
put = async_to_sync_method(AsyncGitHubAPI.put)
89+
delete = async_to_sync_method(AsyncGitHubAPI.delete) # type: ignore[arg-type]
90+
graphql = async_to_sync_method(AsyncGitHubAPI.graphql)
91+
92+
@override # type: ignore[override]
93+
def sleep(self, seconds: float) -> None:
8194
raise NotImplementedError(
82-
"SyncGitHubAPI is planned for a future release. For now, please use AsyncGitHubAPI with async/await."
95+
"sleep() is not supported in SyncGitHubAPI due to abstractmethod"
96+
"gidgethub.abc.GitHubAPI.sleep's async requirements. "
97+
"Use time.sleep() directly instead."
8398
)
8499

100+
@override
101+
def getiter( # type: ignore[override]
102+
self,
103+
url: str,
104+
url_vars: variable.VariableValueDict | None = {},
105+
*,
106+
accept: str = sansio.accept_format(),
107+
jwt: str | None = None,
108+
oauth_token: str | None = None,
109+
extra_headers: dict[str, str] | None = None,
110+
iterable_key: str | None = gh_abc.ITERABLE_KEY,
111+
) -> Generator[Any, None, None]:
112+
data, more, _ = async_to_sync(super()._make_request)(
113+
"GET",
114+
url,
115+
url_vars,
116+
b"",
117+
accept,
118+
jwt=jwt,
119+
oauth_token=oauth_token,
120+
extra_headers=extra_headers,
121+
)
122+
123+
if isinstance(data, dict) and iterable_key in data:
124+
data = data[iterable_key]
125+
126+
yield from data
127+
128+
if more:
129+
yield from self.getiter(
130+
more,
131+
url_vars,
132+
accept=accept,
133+
jwt=jwt,
134+
oauth_token=oauth_token,
135+
iterable_key=iterable_key,
136+
extra_headers=extra_headers,
137+
)
138+
85139

86140
class GitHubAPIEndpoint(Enum):
87141
INSTALLATION_REPOS = "/installation/repositories"

src/django_github_app/views.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import time
34
from abc import ABC
45
from abc import abstractmethod
56
from collections.abc import Coroutine
@@ -107,8 +108,8 @@ def post(self, request: HttpRequest) -> JsonResponse: # pragma: no cover
107108
event_log = EventLog.objects.create_from_event(event)
108109
installation = Installation.objects.get_from_event(event)
109110

110-
with self.get_github_api(installation) as gh: # type: ignore
111-
gh.sleep(1)
111+
with self.get_github_api(installation) as gh:
112+
time.sleep(1)
112113
self.router.dispatch(event, gh) # type: ignore
113114

114115
return self.get_response(event_log)

tests/test_github.py

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,16 +44,110 @@ async def test_oauth_token_no_installation_id(self):
4444
async def test_sleep(self):
4545
delay = 0.25
4646
start = datetime.datetime.now()
47+
4748
async with AsyncGitHubAPI("test") as gh:
4849
await gh.sleep(delay)
50+
4951
stop = datetime.datetime.now()
5052
assert (stop - start) > datetime.timedelta(seconds=delay)
5153

5254

5355
class TestSyncGitHubAPI:
54-
def test_not_implemented_error(self):
56+
def test_getitem(self, httpx_mock):
57+
httpx_mock.add_response(json={"foo": "bar"})
58+
59+
with SyncGitHubAPI("test") as gh:
60+
response = gh.getitem("/foo")
61+
62+
assert response == {"foo": "bar"}
63+
64+
def test_getstatus(self, httpx_mock):
65+
httpx_mock.add_response(status_code=204)
66+
67+
with SyncGitHubAPI("test") as gh:
68+
status = gh.getstatus("/foo")
69+
70+
assert status == 204
71+
72+
def test_post(self, httpx_mock):
73+
httpx_mock.add_response(json={"created": "success"})
74+
75+
with SyncGitHubAPI("test") as gh:
76+
response = gh.post("/foo", data={"key": "value"})
77+
78+
assert response == {"created": "success"}
79+
80+
def test_patch(self, httpx_mock):
81+
httpx_mock.add_response(json={"updated": "success"})
82+
83+
with SyncGitHubAPI("test") as gh:
84+
response = gh.patch("/foo", data={"key": "value"})
85+
86+
assert response == {"updated": "success"}
87+
88+
def test_put(self, httpx_mock):
89+
httpx_mock.add_response(json={"replaced": "success"})
90+
91+
with SyncGitHubAPI("test") as gh:
92+
response = gh.put("/foo", data={"key": "value"})
93+
94+
assert response == {"replaced": "success"}
95+
96+
def test_delete(self, httpx_mock):
97+
httpx_mock.add_response(status_code=204)
98+
99+
with SyncGitHubAPI("test") as gh:
100+
response = gh.delete("/foo")
101+
102+
assert response is None # assuming 204 returns None
103+
104+
def test_graphql(self, httpx_mock):
105+
httpx_mock.add_response(json={"data": {"viewer": {"login": "octocat"}}})
106+
107+
with SyncGitHubAPI("test") as gh:
108+
response = gh.graphql("""
109+
query {
110+
viewer {
111+
login
112+
}
113+
}
114+
""")
115+
116+
assert response == {"viewer": {"login": "octocat"}}
117+
118+
def test_sleep(self):
55119
with pytest.raises(NotImplementedError):
56-
SyncGitHubAPI("not-implemented")
120+
with SyncGitHubAPI("test") as gh:
121+
gh.sleep(1)
122+
123+
def test_getiter(self, httpx_mock):
124+
httpx_mock.add_response(json={"items": [{"id": 1}, {"id": 2}]})
125+
126+
with SyncGitHubAPI("test") as gh:
127+
items = list(gh.getiter("/foo"))
128+
129+
assert items == [{"id": 1}, {"id": 2}]
130+
131+
def test_getiter_pagination(self, httpx_mock):
132+
httpx_mock.add_response(
133+
json={"items": [{"id": 1}]},
134+
headers={"Link": '<next>; rel="next"'},
135+
)
136+
httpx_mock.add_response(json={"items": [{"id": 2}]})
137+
138+
with SyncGitHubAPI("test") as gh:
139+
items = list(gh.getiter("/foo"))
140+
141+
assert items == [{"id": 1}, {"id": 2}]
142+
assert len(httpx_mock.get_requests()) == 2
143+
144+
def test_getiter_list(self, httpx_mock):
145+
httpx_mock.add_response(json=[{"id": 1}, {"id": 2}])
146+
147+
with SyncGitHubAPI("test") as gh:
148+
items = list(gh.getiter("/foo"))
149+
150+
assert items == [{"id": 1}, {"id": 2}]
57151

58152

59153
class TestGitHubAPIUrl:

0 commit comments

Comments
 (0)