Skip to content

Commit a5bf529

Browse files
jarenereArenere, JulioAlberto
andauthored
✨ Feature: support httpx event hooks for github client (#292)
Co-authored-by: Arenere, JulioAlberto <JulioAlberto.Arenere@adidas.com>
1 parent 0453165 commit a5bf529

File tree

6 files changed

+165
-4
lines changed

6 files changed

+165
-4
lines changed

docs/usage/getting-started/configuration.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ github = GitHub(
1717
proxy=None,
1818
transport=None,
1919
async_transport=None,
20+
event_hooks=None,
21+
async_event_hooks=None,
2022
cache_strategy=None,
2123
http_cache=True,
2224
throttler=None,
@@ -44,6 +46,8 @@ config = Config(
4446
proxy=None,
4547
transport=None,
4648
async_transport=None,
49+
event_hooks=None,
50+
async_event_hooks=None,
4751
cache_strategy=DEFAULT_CACHE_STRATEGY,
4852
http_cache=True,
4953
throttler=None,
@@ -164,6 +168,61 @@ github = GitHub(transport=httpx.MockTransport(mock_handler))
164168

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

171+
### `event_hooks`, `async_event_hooks`
172+
173+
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.
174+
175+
| Option | Hook signatures | Used for |
176+
| ------------------- | ------------------------------------------- | -------------- |
177+
| `event_hooks` | `def hook(request)` / `def hook(response)` | Sync requests |
178+
| `async_event_hooks` | `async def hook(request)` / `async def hook(response)` | Async requests |
179+
180+
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.
181+
182+
=== "Sync"
183+
184+
```python
185+
import httpx
186+
from githubkit import GitHub
187+
188+
def log_request(request: httpx.Request) -> None:
189+
print(f"-> {request.method} {request.url}")
190+
191+
def log_response(response: httpx.Response) -> None:
192+
print(f"<- {response.status_code}")
193+
194+
github = GitHub(
195+
event_hooks={
196+
"request": [log_request],
197+
"response": [log_response],
198+
},
199+
)
200+
```
201+
202+
=== "Async"
203+
204+
```python
205+
import httpx
206+
from githubkit import GitHub
207+
208+
async def log_request(request: httpx.Request) -> None:
209+
print(f"-> {request.method} {request.url}")
210+
211+
async def log_response(response: httpx.Response) -> None:
212+
print(f"<- {response.status_code}")
213+
214+
github = GitHub(
215+
async_event_hooks={
216+
"request": [log_request],
217+
"response": [log_response],
218+
},
219+
)
220+
```
221+
222+
!!! note
223+
224+
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).
225+
167226
### `cache_strategy`
168227

169228
Controls how githubkit caches **tokens** (e.g., GitHub App installation tokens) and **HTTP responses**. The default is `MemCacheStrategy`, which stores data in process memory.

githubkit/config.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from .cache import DEFAULT_CACHE_STRATEGY, BaseCacheStrategy
99
from .retry import RETRY_DEFAULT
1010
from .throttling import BaseThrottler, LocalThrottler
11-
from .typing import ProxyTypes, RetryDecisionFunc
11+
from .typing import EventHookTypes, ProxyTypes, RetryDecisionFunc
1212

1313
if TYPE_CHECKING:
1414
import ssl
@@ -26,6 +26,8 @@ class Config:
2626
proxy: Optional[ProxyTypes]
2727
transport: Optional[httpx.BaseTransport]
2828
async_transport: Optional[httpx.AsyncBaseTransport]
29+
event_hooks: Optional[EventHookTypes]
30+
async_event_hooks: Optional[EventHookTypes]
2931
cache_strategy: BaseCacheStrategy
3032
http_cache: bool
3133
throttler: BaseThrottler
@@ -117,6 +119,8 @@ def get_config(
117119
proxy: Optional[ProxyTypes] = None,
118120
transport: Optional[httpx.BaseTransport] = None,
119121
async_transport: Optional[httpx.AsyncBaseTransport] = None,
122+
event_hooks: Optional[EventHookTypes] = None,
123+
async_event_hooks: Optional[EventHookTypes] = None,
120124
cache_strategy: Optional[BaseCacheStrategy] = None,
121125
http_cache: bool = True,
122126
throttler: Optional[BaseThrottler] = None,
@@ -135,6 +139,8 @@ def get_config(
135139
proxy,
136140
transport,
137141
async_transport,
142+
event_hooks,
143+
async_event_hooks,
138144
build_cache_strategy(cache_strategy),
139145
http_cache,
140146
build_throttler(throttler),

githubkit/core.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from .typing import (
2828
ContentTypes,
2929
CookieTypes,
30+
EventHookTypes,
3031
HeaderTypes,
3132
ProxyTypes,
3233
QueryParamTypes,
@@ -90,6 +91,8 @@ def __init__(
9091
proxy: Optional[ProxyTypes] = None,
9192
transport: Optional[httpx.BaseTransport] = None,
9293
async_transport: Optional[httpx.AsyncBaseTransport] = None,
94+
event_hooks: Optional[EventHookTypes] = None,
95+
async_event_hooks: Optional[EventHookTypes] = None,
9396
cache_strategy: Optional[BaseCacheStrategy] = None,
9497
http_cache: bool = True,
9598
throttler: Optional[BaseThrottler] = None,
@@ -114,6 +117,8 @@ def __init__(
114117
proxy: Optional[ProxyTypes] = None,
115118
transport: Optional[httpx.BaseTransport] = None,
116119
async_transport: Optional[httpx.AsyncBaseTransport] = None,
120+
event_hooks: Optional[EventHookTypes] = None,
121+
async_event_hooks: Optional[EventHookTypes] = None,
117122
cache_strategy: Optional[BaseCacheStrategy] = None,
118123
http_cache: bool = True,
119124
throttler: Optional[BaseThrottler] = None,
@@ -138,6 +143,8 @@ def __init__(
138143
proxy: Optional[ProxyTypes] = None,
139144
transport: Optional[httpx.BaseTransport] = None,
140145
async_transport: Optional[httpx.AsyncBaseTransport] = None,
146+
event_hooks: Optional[EventHookTypes] = None,
147+
async_event_hooks: Optional[EventHookTypes] = None,
141148
cache_strategy: Optional[BaseCacheStrategy] = None,
142149
http_cache: bool = True,
143150
throttler: Optional[BaseThrottler] = None,
@@ -161,6 +168,8 @@ def __init__(
161168
proxy: Optional[ProxyTypes] = None,
162169
transport: Optional[httpx.BaseTransport] = None,
163170
async_transport: Optional[httpx.AsyncBaseTransport] = None,
171+
event_hooks: Optional[EventHookTypes] = None,
172+
async_event_hooks: Optional[EventHookTypes] = None,
164173
cache_strategy: Optional[BaseCacheStrategy] = None,
165174
http_cache: bool = True,
166175
throttler: Optional[BaseThrottler] = None,
@@ -184,6 +193,8 @@ def __init__(
184193
proxy=proxy,
185194
transport=transport,
186195
async_transport=async_transport,
196+
event_hooks=event_hooks,
197+
async_event_hooks=async_event_hooks,
187198
cache_strategy=cache_strategy,
188199
http_cache=http_cache,
189200
throttler=throttler,
@@ -252,12 +263,15 @@ def _create_sync_client(self) -> httpx.Client:
252263
return hishel.CacheClient(
253264
**self._get_client_defaults(),
254265
transport=self.config.transport,
266+
event_hooks=self.config.event_hooks,
255267
storage=self.config.cache_strategy.get_hishel_storage(),
256268
controller=self.config.cache_strategy.get_hishel_controller(),
257269
)
258270

259271
return httpx.Client(
260-
**self._get_client_defaults(), transport=self.config.transport
272+
**self._get_client_defaults(),
273+
transport=self.config.transport,
274+
event_hooks=self.config.event_hooks,
261275
)
262276

263277
# get or create sync client
@@ -277,12 +291,15 @@ def _create_async_client(self) -> httpx.AsyncClient:
277291
return hishel.AsyncCacheClient(
278292
**self._get_client_defaults(),
279293
transport=self.config.async_transport,
294+
event_hooks=self.config.async_event_hooks,
280295
storage=self.config.cache_strategy.get_async_hishel_storage(),
281296
controller=self.config.cache_strategy.get_hishel_controller(),
282297
)
283298

284299
return httpx.AsyncClient(
285-
**self._get_client_defaults(), transport=self.config.async_transport
300+
**self._get_client_defaults(),
301+
transport=self.config.async_transport,
302+
event_hooks=self.config.async_event_hooks,
286303
)
287304

288305
# get or create async client

githubkit/github.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from .graphql import GraphQLNamespace
99
from .paginator import Paginator
1010
from .response import Response
11-
from .typing import ProxyTypes, RetryDecisionFunc
11+
from .typing import EventHookTypes, ProxyTypes, RetryDecisionFunc
1212
from .versions import RestVersionSwitcher, WebhooksVersionSwitcher
1313

1414
if TYPE_CHECKING:
@@ -81,6 +81,8 @@ def __init__(
8181
proxy: Optional[ProxyTypes] = None,
8282
transport: Optional[httpx.BaseTransport] = None,
8383
async_transport: Optional[httpx.AsyncBaseTransport] = None,
84+
event_hooks: Optional[EventHookTypes] = None,
85+
async_event_hooks: Optional[EventHookTypes] = None,
8486
cache_strategy: Optional["BaseCacheStrategy"] = None,
8587
http_cache: bool = True,
8688
throttler: Optional["BaseThrottler"] = None,
@@ -105,6 +107,8 @@ def __init__(
105107
proxy: Optional[ProxyTypes] = None,
106108
transport: Optional[httpx.BaseTransport] = None,
107109
async_transport: Optional[httpx.AsyncBaseTransport] = None,
110+
event_hooks: Optional[EventHookTypes] = None,
111+
async_event_hooks: Optional[EventHookTypes] = None,
108112
cache_strategy: Optional["BaseCacheStrategy"] = None,
109113
http_cache: bool = True,
110114
throttler: Optional["BaseThrottler"] = None,
@@ -129,6 +133,8 @@ def __init__(
129133
proxy: Optional[ProxyTypes] = None,
130134
transport: Optional[httpx.BaseTransport] = None,
131135
async_transport: Optional[httpx.AsyncBaseTransport] = None,
136+
event_hooks: Optional[EventHookTypes] = None,
137+
async_event_hooks: Optional[EventHookTypes] = None,
132138
cache_strategy: Optional["BaseCacheStrategy"] = None,
133139
http_cache: bool = True,
134140
throttler: Optional["BaseThrottler"] = None,

githubkit/typing.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
IO,
55
TYPE_CHECKING,
66
Annotated,
7+
Any,
78
Callable,
89
Literal,
910
NamedTuple,
@@ -99,6 +100,9 @@ class RetryOption(NamedTuple):
99100
RetryDecisionFunc: TypeAlias = Callable[[GitHubException, int], RetryOption]
100101

101102

103+
EventHookTypes: TypeAlias = Mapping[str, list[Callable[..., Any]]]
104+
105+
102106
class HishelControllerOptions(TypedDict, total=False):
103107
"""Options for the hishel controller."""
104108

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import json
2+
from pathlib import Path
3+
4+
import httpx
5+
import pytest
6+
7+
from githubkit import GitHub
8+
from githubkit.versions.latest.models import FullRepository
9+
10+
FAKE_RESPONSE = json.loads((Path(__file__).parent / "fake_response.json").read_text())
11+
12+
13+
def mock_transport_handler(request: httpx.Request) -> httpx.Response:
14+
if request.method == "GET" and request.url.path == "/repos/owner/repo":
15+
return httpx.Response(status_code=200, json=FAKE_RESPONSE)
16+
raise RuntimeError(f"Unexpected request: {request.method} {request.url.path}")
17+
18+
19+
def test_sync_event_hooks():
20+
requests_seen: list[httpx.Request] = []
21+
responses_seen: list[httpx.Response] = []
22+
23+
def on_request(request: httpx.Request) -> None:
24+
requests_seen.append(request)
25+
26+
def on_response(response: httpx.Response) -> None:
27+
responses_seen.append(response)
28+
29+
g = GitHub(
30+
"xxxxx",
31+
transport=httpx.MockTransport(mock_transport_handler),
32+
event_hooks={
33+
"request": [on_request],
34+
"response": [on_response],
35+
},
36+
)
37+
resp = g.rest.repos.get("owner", "repo")
38+
assert isinstance(resp.parsed_data, FullRepository)
39+
assert len(requests_seen) == 1
40+
assert len(responses_seen) == 1
41+
assert requests_seen[0].url.path == "/repos/owner/repo"
42+
assert responses_seen[0].status_code == 200
43+
44+
45+
@pytest.mark.anyio
46+
async def test_async_event_hooks():
47+
requests_seen: list[httpx.Request] = []
48+
responses_seen: list[httpx.Response] = []
49+
50+
async def on_request(request: httpx.Request) -> None:
51+
requests_seen.append(request)
52+
53+
async def on_response(response: httpx.Response) -> None:
54+
responses_seen.append(response)
55+
56+
g = GitHub(
57+
"xxxxx",
58+
async_transport=httpx.MockTransport(mock_transport_handler),
59+
async_event_hooks={
60+
"request": [on_request],
61+
"response": [on_response],
62+
},
63+
)
64+
resp = await g.rest.repos.async_get("owner", "repo")
65+
assert isinstance(resp.parsed_data, FullRepository)
66+
assert len(requests_seen) == 1
67+
assert len(responses_seen) == 1
68+
assert requests_seen[0].url.path == "/repos/owner/repo"
69+
assert responses_seen[0].status_code == 200

0 commit comments

Comments
 (0)