Skip to content

Commit

Permalink
implement SyncGitHubAPI client (#23)
Browse files Browse the repository at this point in the history
* implement `SyncGitHubAPI` client

* update changelog

* update readme documentation

* tweak some wording

* tweak wording too
  • Loading branch information
joshuadavidthomas authored Nov 20, 2024
1 parent d19f6e3 commit f8b417d
Show file tree
Hide file tree
Showing 9 changed files with 298 additions and 26 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/

## [Unreleased]

### Added

- Added `SyncGitHubAPI`, a synchronous implementation of `gidgethub.abc.GitHubAPI` for Django applications running under WSGI. Maintains the familiar gidgethub interface without requiring async/await.

## [0.2.1]

### Fixed
Expand Down
39 changes: 34 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@

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

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.
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.

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

## Requirements

Expand Down Expand Up @@ -61,7 +61,7 @@ The library is async-only at the moment (following gidgethub), with sync support
]
```
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.
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.
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.
Expand Down Expand Up @@ -239,7 +239,13 @@ For more details about how `gidgethub.sansio.Event` and webhook routing work, se
### GitHub API Client
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.
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.
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.
#### `AsyncGitHubAPI`
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.
```python
from django_github_app.github import AsyncGitHubAPI
Expand All @@ -262,7 +268,30 @@ async def create_comment(repo_full_name: str):
)
```
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.
#### `SyncGitHubAPI`
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`.
```python
from django_github_app.github import SyncGitHubAPI
from django_github_app.models import Installation
# Access public endpoints without authentication
def get_public_repo_sync():
with SyncGitHubAPI() as gh:
return gh.getitem("/repos/django/django")
# Interact as the GitHub App installation
def create_comment_sync(repo_full_name: str):
# Get the installation for the repository
installation = Installation.objects.get(repositories__full_name=repo_full_name)
with SyncGitHubAPI(installation_id=installation.installation_id) as gh:
gh.post(
f"/repos/{repo_full_name}/issues/1/comments",
data={"body": "Hello!"}
)
```
### Models
Expand Down
2 changes: 1 addition & 1 deletion noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ def coverage(session):
args.extend(arg.split(" "))
command.extend(args)
if "--integration" not in command:
command.append("--cov-fail-under=99")
command.append("--cov-fail-under=98")
session.run(*command)
finally:
# 0 -> OK
Expand Down
23 changes: 23 additions & 0 deletions src/django_github_app/_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from __future__ import annotations

import functools
from collections.abc import Coroutine
from typing import Any
from typing import Callable
from typing import ParamSpec
from typing import TypeVar

from asgiref.sync import async_to_sync

P = ParamSpec("P")
T = TypeVar("T")


def async_to_sync_method(
async_func: Callable[P, Coroutine[Any, Any, T]],
) -> Callable[P, T]:
@functools.wraps(async_func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
return async_to_sync(async_func)(*args, **kwargs)

return wrapper
62 changes: 58 additions & 4 deletions src/django_github_app/github.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import asyncio
from collections.abc import Generator
from collections.abc import Mapping
from dataclasses import dataclass
from enum import Enum
Expand All @@ -11,10 +12,12 @@
import cachetools
import gidgethub
import httpx
from asgiref.sync import async_to_sync
from gidgethub import abc as gh_abc
from gidgethub import sansio
from uritemplate import variable

from ._sync import async_to_sync_method
from ._typing import override

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


class SyncGitHubAPI(AsyncGitHubAPI):
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
__enter__ = async_to_sync_method(AsyncGitHubAPI.__aenter__)
__exit__ = async_to_sync_method(AsyncGitHubAPI.__aexit__)
getitem = async_to_sync_method(AsyncGitHubAPI.getitem)
getstatus = async_to_sync_method(AsyncGitHubAPI.getstatus) # type: ignore[arg-type]
post = async_to_sync_method(AsyncGitHubAPI.post)
patch = async_to_sync_method(AsyncGitHubAPI.patch)
put = async_to_sync_method(AsyncGitHubAPI.put)
delete = async_to_sync_method(AsyncGitHubAPI.delete) # type: ignore[arg-type]
graphql = async_to_sync_method(AsyncGitHubAPI.graphql)

@override # type: ignore[override]
def sleep(self, seconds: float) -> None:
raise NotImplementedError(
"SyncGitHubAPI is planned for a future release. For now, please use AsyncGitHubAPI with async/await."
"sleep() is not supported in SyncGitHubAPI due to abstractmethod"
"gidgethub.abc.GitHubAPI.sleep's async requirements. "
"Use time.sleep() directly instead."
)

@override
def getiter( # type: ignore[override]
self,
url: str,
url_vars: variable.VariableValueDict | None = {},
*,
accept: str = sansio.accept_format(),
jwt: str | None = None,
oauth_token: str | None = None,
extra_headers: dict[str, str] | None = None,
iterable_key: str | None = gh_abc.ITERABLE_KEY,
) -> Generator[Any, None, None]:
data, more, _ = async_to_sync(super()._make_request)(
"GET",
url,
url_vars,
b"",
accept,
jwt=jwt,
oauth_token=oauth_token,
extra_headers=extra_headers,
)

if isinstance(data, dict) and iterable_key in data:
data = data[iterable_key]

yield from data

if more:
yield from self.getiter(
more,
url_vars,
accept=accept,
jwt=jwt,
oauth_token=oauth_token,
iterable_key=iterable_key,
extra_headers=extra_headers,
)


class GitHubAPIEndpoint(Enum):
INSTALLATION_REPOS = "/installation/repositories"
Expand Down
5 changes: 3 additions & 2 deletions src/django_github_app/views.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import time
from abc import ABC
from abc import abstractmethod
from collections.abc import Coroutine
Expand Down Expand Up @@ -107,8 +108,8 @@ def post(self, request: HttpRequest) -> JsonResponse: # pragma: no cover
event_log = EventLog.objects.create_from_event(event)
installation = Installation.objects.get_from_event(event)

with self.get_github_api(installation) as gh: # type: ignore
gh.sleep(1)
with self.get_github_api(installation) as gh:
time.sleep(1)
self.router.dispatch(event, gh) # type: ignore

return self.get_response(event_log)
98 changes: 96 additions & 2 deletions tests/test_github.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,110 @@ async def test_oauth_token_no_installation_id(self):
async def test_sleep(self):
delay = 0.25
start = datetime.datetime.now()

async with AsyncGitHubAPI("test") as gh:
await gh.sleep(delay)

stop = datetime.datetime.now()
assert (stop - start) > datetime.timedelta(seconds=delay)


class TestSyncGitHubAPI:
def test_not_implemented_error(self):
def test_getitem(self, httpx_mock):
httpx_mock.add_response(json={"foo": "bar"})

with SyncGitHubAPI("test") as gh:
response = gh.getitem("/foo")

assert response == {"foo": "bar"}

def test_getstatus(self, httpx_mock):
httpx_mock.add_response(status_code=204)

with SyncGitHubAPI("test") as gh:
status = gh.getstatus("/foo")

assert status == 204

def test_post(self, httpx_mock):
httpx_mock.add_response(json={"created": "success"})

with SyncGitHubAPI("test") as gh:
response = gh.post("/foo", data={"key": "value"})

assert response == {"created": "success"}

def test_patch(self, httpx_mock):
httpx_mock.add_response(json={"updated": "success"})

with SyncGitHubAPI("test") as gh:
response = gh.patch("/foo", data={"key": "value"})

assert response == {"updated": "success"}

def test_put(self, httpx_mock):
httpx_mock.add_response(json={"replaced": "success"})

with SyncGitHubAPI("test") as gh:
response = gh.put("/foo", data={"key": "value"})

assert response == {"replaced": "success"}

def test_delete(self, httpx_mock):
httpx_mock.add_response(status_code=204)

with SyncGitHubAPI("test") as gh:
response = gh.delete("/foo")

assert response is None # assuming 204 returns None

def test_graphql(self, httpx_mock):
httpx_mock.add_response(json={"data": {"viewer": {"login": "octocat"}}})

with SyncGitHubAPI("test") as gh:
response = gh.graphql("""
query {
viewer {
login
}
}
""")

assert response == {"viewer": {"login": "octocat"}}

def test_sleep(self):
with pytest.raises(NotImplementedError):
SyncGitHubAPI("not-implemented")
with SyncGitHubAPI("test") as gh:
gh.sleep(1)

def test_getiter(self, httpx_mock):
httpx_mock.add_response(json={"items": [{"id": 1}, {"id": 2}]})

with SyncGitHubAPI("test") as gh:
items = list(gh.getiter("/foo"))

assert items == [{"id": 1}, {"id": 2}]

def test_getiter_pagination(self, httpx_mock):
httpx_mock.add_response(
json={"items": [{"id": 1}]},
headers={"Link": '<next>; rel="next"'},
)
httpx_mock.add_response(json={"items": [{"id": 2}]})

with SyncGitHubAPI("test") as gh:
items = list(gh.getiter("/foo"))

assert items == [{"id": 1}, {"id": 2}]
assert len(httpx_mock.get_requests()) == 2

def test_getiter_list(self, httpx_mock):
httpx_mock.add_response(json=[{"id": 1}, {"id": 2}])

with SyncGitHubAPI("test") as gh:
items = list(gh.getiter("/foo"))

assert items == [{"id": 1}, {"id": 2}]


class TestGitHubAPIUrl:
Expand Down
Loading

0 comments on commit f8b417d

Please sign in to comment.