Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions docs/usage/getting-started/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ github = GitHub(
proxy=None,
transport=None,
async_transport=None,
event_hooks=None,
async_event_hooks=None,
cache_strategy=None,
http_cache=True,
throttler=None,
Expand Down Expand Up @@ -44,6 +46,8 @@ config = Config(
proxy=None,
transport=None,
async_transport=None,
event_hooks=None,
async_event_hooks=None,
cache_strategy=DEFAULT_CACHE_STRATEGY,
http_cache=True,
throttler=None,
Expand Down Expand Up @@ -164,6 +168,61 @@ github = GitHub(transport=httpx.MockTransport(mock_handler))

When a custom transport is provided, proxy-related environment variables (`HTTP_PROXY`, etc.) have no effect. Set `transport` / `async_transport` to `None` (default) to use HTTPX's built-in transport.

### `event_hooks`, `async_event_hooks`

Register [HTTPX event hooks](https://www.python-httpx.org/advanced/event-hooks/) that run on every request and/or response. This is useful for logging, injecting headers, collecting metrics, or raising on error status codes — without modifying your business logic.

| Option | Hook signatures | Used for |
| ------------------- | ------------------------------------------- | -------------- |
| `event_hooks` | `def hook(request)` / `def hook(response)` | Sync requests |
| `async_event_hooks` | `async def hook(request)` / `async def hook(response)` | Async requests |

Both options accept a dictionary mapping event names (`"request"`, `"response"`) to a list of callables. Each callable receives an `httpx.Request` or `httpx.Response` object respectively.

=== "Sync"

```python
import httpx
from githubkit import GitHub

def log_request(request: httpx.Request) -> None:
print(f"-> {request.method} {request.url}")

def log_response(response: httpx.Response) -> None:
print(f"<- {response.status_code}")

github = GitHub(
event_hooks={
"request": [log_request],
"response": [log_response],
},
)
```

=== "Async"

```python
import httpx
from githubkit import GitHub

async def log_request(request: httpx.Request) -> None:
print(f"-> {request.method} {request.url}")

async def log_response(response: httpx.Response) -> None:
print(f"<- {response.status_code}")

github = GitHub(
async_event_hooks={
"request": [log_request],
"response": [log_response],
},
)
```

!!! note

Response hooks are called **before** the response body is read. If you need to access the body inside a hook, call `response.read()` (sync) or `await response.aread()` (async).

### `cache_strategy`

Controls how githubkit caches **tokens** (e.g., GitHub App installation tokens) and **HTTP responses**. The default is `MemCacheStrategy`, which stores data in process memory.
Expand Down
8 changes: 7 additions & 1 deletion githubkit/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from .cache import DEFAULT_CACHE_STRATEGY, BaseCacheStrategy
from .retry import RETRY_DEFAULT
from .throttling import BaseThrottler, LocalThrottler
from .typing import ProxyTypes, RetryDecisionFunc
from .typing import EventHookTypes, ProxyTypes, RetryDecisionFunc

if TYPE_CHECKING:
import ssl
Expand All @@ -26,6 +26,8 @@ class Config:
proxy: Optional[ProxyTypes]
transport: Optional[httpx.BaseTransport]
async_transport: Optional[httpx.AsyncBaseTransport]
event_hooks: Optional[EventHookTypes]
async_event_hooks: Optional[EventHookTypes]
cache_strategy: BaseCacheStrategy
http_cache: bool
throttler: BaseThrottler
Expand Down Expand Up @@ -117,6 +119,8 @@ def get_config(
proxy: Optional[ProxyTypes] = None,
transport: Optional[httpx.BaseTransport] = None,
async_transport: Optional[httpx.AsyncBaseTransport] = None,
event_hooks: Optional[EventHookTypes] = None,
async_event_hooks: Optional[EventHookTypes] = None,
cache_strategy: Optional[BaseCacheStrategy] = None,
http_cache: bool = True,
throttler: Optional[BaseThrottler] = None,
Expand All @@ -135,6 +139,8 @@ def get_config(
proxy,
transport,
async_transport,
event_hooks,
async_event_hooks,
build_cache_strategy(cache_strategy),
http_cache,
build_throttler(throttler),
Expand Down
21 changes: 19 additions & 2 deletions githubkit/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from .typing import (
ContentTypes,
CookieTypes,
EventHookTypes,
HeaderTypes,
ProxyTypes,
QueryParamTypes,
Expand Down Expand Up @@ -90,6 +91,8 @@ def __init__(
proxy: Optional[ProxyTypes] = None,
transport: Optional[httpx.BaseTransport] = None,
async_transport: Optional[httpx.AsyncBaseTransport] = None,
event_hooks: Optional[EventHookTypes] = None,
async_event_hooks: Optional[EventHookTypes] = None,
cache_strategy: Optional[BaseCacheStrategy] = None,
http_cache: bool = True,
throttler: Optional[BaseThrottler] = None,
Expand All @@ -114,6 +117,8 @@ def __init__(
proxy: Optional[ProxyTypes] = None,
transport: Optional[httpx.BaseTransport] = None,
async_transport: Optional[httpx.AsyncBaseTransport] = None,
event_hooks: Optional[EventHookTypes] = None,
async_event_hooks: Optional[EventHookTypes] = None,
cache_strategy: Optional[BaseCacheStrategy] = None,
http_cache: bool = True,
throttler: Optional[BaseThrottler] = None,
Expand All @@ -138,6 +143,8 @@ def __init__(
proxy: Optional[ProxyTypes] = None,
transport: Optional[httpx.BaseTransport] = None,
async_transport: Optional[httpx.AsyncBaseTransport] = None,
event_hooks: Optional[EventHookTypes] = None,
async_event_hooks: Optional[EventHookTypes] = None,
cache_strategy: Optional[BaseCacheStrategy] = None,
http_cache: bool = True,
throttler: Optional[BaseThrottler] = None,
Expand All @@ -161,6 +168,8 @@ def __init__(
proxy: Optional[ProxyTypes] = None,
transport: Optional[httpx.BaseTransport] = None,
async_transport: Optional[httpx.AsyncBaseTransport] = None,
event_hooks: Optional[EventHookTypes] = None,
async_event_hooks: Optional[EventHookTypes] = None,
cache_strategy: Optional[BaseCacheStrategy] = None,
http_cache: bool = True,
throttler: Optional[BaseThrottler] = None,
Expand All @@ -184,6 +193,8 @@ def __init__(
proxy=proxy,
transport=transport,
async_transport=async_transport,
event_hooks=event_hooks,
async_event_hooks=async_event_hooks,
cache_strategy=cache_strategy,
http_cache=http_cache,
throttler=throttler,
Expand Down Expand Up @@ -252,12 +263,15 @@ def _create_sync_client(self) -> httpx.Client:
return hishel.CacheClient(
**self._get_client_defaults(),
transport=self.config.transport,
event_hooks=self.config.event_hooks,
storage=self.config.cache_strategy.get_hishel_storage(),
controller=self.config.cache_strategy.get_hishel_controller(),
)

return httpx.Client(
**self._get_client_defaults(), transport=self.config.transport
**self._get_client_defaults(),
transport=self.config.transport,
event_hooks=self.config.event_hooks,
)

# get or create sync client
Expand All @@ -277,12 +291,15 @@ def _create_async_client(self) -> httpx.AsyncClient:
return hishel.AsyncCacheClient(
**self._get_client_defaults(),
transport=self.config.async_transport,
event_hooks=self.config.async_event_hooks,
storage=self.config.cache_strategy.get_async_hishel_storage(),
controller=self.config.cache_strategy.get_hishel_controller(),
)

return httpx.AsyncClient(
**self._get_client_defaults(), transport=self.config.async_transport
**self._get_client_defaults(),
transport=self.config.async_transport,
event_hooks=self.config.async_event_hooks,
)

# get or create async client
Expand Down
8 changes: 7 additions & 1 deletion githubkit/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from .graphql import GraphQLNamespace
from .paginator import Paginator
from .response import Response
from .typing import ProxyTypes, RetryDecisionFunc
from .typing import EventHookTypes, ProxyTypes, RetryDecisionFunc
from .versions import RestVersionSwitcher, WebhooksVersionSwitcher

if TYPE_CHECKING:
Expand Down Expand Up @@ -81,6 +81,8 @@ def __init__(
proxy: Optional[ProxyTypes] = None,
transport: Optional[httpx.BaseTransport] = None,
async_transport: Optional[httpx.AsyncBaseTransport] = None,
event_hooks: Optional[EventHookTypes] = None,
async_event_hooks: Optional[EventHookTypes] = None,
cache_strategy: Optional["BaseCacheStrategy"] = None,
http_cache: bool = True,
throttler: Optional["BaseThrottler"] = None,
Expand All @@ -105,6 +107,8 @@ def __init__(
proxy: Optional[ProxyTypes] = None,
transport: Optional[httpx.BaseTransport] = None,
async_transport: Optional[httpx.AsyncBaseTransport] = None,
event_hooks: Optional[EventHookTypes] = None,
async_event_hooks: Optional[EventHookTypes] = None,
cache_strategy: Optional["BaseCacheStrategy"] = None,
http_cache: bool = True,
throttler: Optional["BaseThrottler"] = None,
Expand All @@ -129,6 +133,8 @@ def __init__(
proxy: Optional[ProxyTypes] = None,
transport: Optional[httpx.BaseTransport] = None,
async_transport: Optional[httpx.AsyncBaseTransport] = None,
event_hooks: Optional[EventHookTypes] = None,
async_event_hooks: Optional[EventHookTypes] = None,
cache_strategy: Optional["BaseCacheStrategy"] = None,
http_cache: bool = True,
throttler: Optional["BaseThrottler"] = None,
Expand Down
4 changes: 4 additions & 0 deletions githubkit/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
IO,
TYPE_CHECKING,
Annotated,
Any,
Callable,
Literal,
NamedTuple,
Expand Down Expand Up @@ -99,6 +100,9 @@ class RetryOption(NamedTuple):
RetryDecisionFunc: TypeAlias = Callable[[GitHubException, int], RetryOption]


EventHookTypes: TypeAlias = Mapping[str, list[Callable[..., Any]]]


class HishelControllerOptions(TypedDict, total=False):
"""Options for the hishel controller."""

Expand Down
69 changes: 69 additions & 0 deletions tests/test_unit_test/test_event_hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import json
from pathlib import Path

import httpx
import pytest

from githubkit import GitHub
from githubkit.versions.latest.models import FullRepository

FAKE_RESPONSE = json.loads((Path(__file__).parent / "fake_response.json").read_text())


def mock_transport_handler(request: httpx.Request) -> httpx.Response:
if request.method == "GET" and request.url.path == "/repos/owner/repo":
return httpx.Response(status_code=200, json=FAKE_RESPONSE)
raise RuntimeError(f"Unexpected request: {request.method} {request.url.path}")


def test_sync_event_hooks():
requests_seen: list[httpx.Request] = []
responses_seen: list[httpx.Response] = []

def on_request(request: httpx.Request) -> None:
requests_seen.append(request)

def on_response(response: httpx.Response) -> None:
responses_seen.append(response)

g = GitHub(
"xxxxx",
transport=httpx.MockTransport(mock_transport_handler),
event_hooks={
"request": [on_request],
"response": [on_response],
},
)
resp = g.rest.repos.get("owner", "repo")
assert isinstance(resp.parsed_data, FullRepository)
assert len(requests_seen) == 1
assert len(responses_seen) == 1
assert requests_seen[0].url.path == "/repos/owner/repo"
assert responses_seen[0].status_code == 200


@pytest.mark.anyio
async def test_async_event_hooks():
requests_seen: list[httpx.Request] = []
responses_seen: list[httpx.Response] = []

async def on_request(request: httpx.Request) -> None:
requests_seen.append(request)

async def on_response(response: httpx.Response) -> None:
responses_seen.append(response)

g = GitHub(
"xxxxx",
async_transport=httpx.MockTransport(mock_transport_handler),
async_event_hooks={
"request": [on_request],
"response": [on_response],
},
)
resp = await g.rest.repos.async_get("owner", "repo")
assert isinstance(resp.parsed_data, FullRepository)
assert len(requests_seen) == 1
assert len(responses_seen) == 1
assert requests_seen[0].url.path == "/repos/owner/repo"
assert responses_seen[0].status_code == 200
Loading