From e4d1570e24aaef4d6ddf89a159483628e6bcd868 Mon Sep 17 00:00:00 2001 From: "Arenere, JulioAlberto" Date: Fri, 10 Apr 2026 09:11:57 +0200 Subject: [PATCH] feat: add support for event hooks in GitHub client and configuration --- docs/usage/getting-started/configuration.md | 59 ++++++++++++++++++ githubkit/config.py | 8 ++- githubkit/core.py | 21 ++++++- githubkit/github.py | 8 ++- githubkit/typing.py | 4 ++ tests/test_unit_test/test_event_hooks.py | 69 +++++++++++++++++++++ 6 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 tests/test_unit_test/test_event_hooks.py diff --git a/docs/usage/getting-started/configuration.md b/docs/usage/getting-started/configuration.md index 9d0adc980..8efd31fde 100644 --- a/docs/usage/getting-started/configuration.md +++ b/docs/usage/getting-started/configuration.md @@ -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, @@ -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, @@ -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. diff --git a/githubkit/config.py b/githubkit/config.py index 0560d67cf..1635226f2 100644 --- a/githubkit/config.py +++ b/githubkit/config.py @@ -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 @@ -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 @@ -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, @@ -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), diff --git a/githubkit/core.py b/githubkit/core.py index b9b4f5067..9da5cf316 100644 --- a/githubkit/core.py +++ b/githubkit/core.py @@ -27,6 +27,7 @@ from .typing import ( ContentTypes, CookieTypes, + EventHookTypes, HeaderTypes, ProxyTypes, QueryParamTypes, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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 @@ -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 diff --git a/githubkit/github.py b/githubkit/github.py index 6779afce0..77d6f7c7b 100644 --- a/githubkit/github.py +++ b/githubkit/github.py @@ -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: @@ -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, @@ -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, @@ -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, diff --git a/githubkit/typing.py b/githubkit/typing.py index 21f6059d8..fc51f95ec 100644 --- a/githubkit/typing.py +++ b/githubkit/typing.py @@ -4,6 +4,7 @@ IO, TYPE_CHECKING, Annotated, + Any, Callable, Literal, NamedTuple, @@ -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.""" diff --git a/tests/test_unit_test/test_event_hooks.py b/tests/test_unit_test/test_event_hooks.py new file mode 100644 index 000000000..49916ae10 --- /dev/null +++ b/tests/test_unit_test/test_event_hooks.py @@ -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