Skip to content
Merged
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
19 changes: 13 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ uv build
### Core Components

**octohook/__init__.py** - Entry point providing:
- `load_hooks(modules)` - Recursively imports modules to register webhook handlers
- `model_overrides` - Global dict for extending/replacing model classes
- Exports: `hook`, `handle_webhook`, `parse`, `WebhookEvent`, `WebhookEventAction`
- `setup(modules, model_overrides)` - Configures octohook by loading webhook handlers and registering model overrides. Validates that model overrides inherit from base classes. Raises on import errors. Always calls reset() first to clear existing state.
- `reset()` - Clears all registered hooks, imported modules, and model overrides. Returns octohook to unconfigured state.
- `_model_overrides` - Internal dict for extending/replacing model classes (private, set via setup())
- `OctohookConfigError` - Exception raised for configuration errors
- Exports: `hook`, `handle_webhook`, `parse`, `setup`, `reset`, `WebhookEvent`, `WebhookEventAction`, `OctohookConfigError`

**octohook/decorators.py** - Decorator system (`_WebhookDecorator` class):
- `@hook(event, actions, repositories, debug)` - Registers functions as webhook handlers
Expand Down Expand Up @@ -63,7 +65,7 @@ Handler resolution order:

### Model Override System

Users can extend/replace models via `model_overrides`:
Users can extend/replace models via `setup()`:

```python
from octohook.models import PullRequest
Expand All @@ -72,10 +74,13 @@ class MyPullRequest(PullRequest):
def custom_method(self):
pass

octohook.model_overrides = {PullRequest: MyPullRequest}
octohook.setup(
modules=["hooks"],
model_overrides={PullRequest: MyPullRequest}
)
```

When any model is instantiated, `BaseGithubModel.__new__` checks `model_overrides` and substitutes the custom class. This allows adding custom methods/properties to GitHub objects without modifying octohook source.
When any model is instantiated, `BaseGithubModel.__new__` checks `_model_overrides` and substitutes the custom class. This allows adding custom methods/properties to GitHub objects without modifying octohook source.

### Payload Inconsistencies

Expand All @@ -91,3 +96,5 @@ Octohook uses `Optional` types extensively and the `_optional()` helper to handl
- Test fixtures in `tests/fixtures/complete/` contain real GitHub webhook payloads
- Hook tests verify that the decorator system correctly routes events to handlers
- Model tests verify parsing and URL interpolation
- `tests/conftest.py` provides an autouse fixture that calls `reset()` before/after each test for isolation
- The autouse fixture also clears test module imports from `sys.modules` to ensure decorators re-register on each test
28 changes: 15 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ def webhook():
### @hook
Alternatively, you can also let `octohook` do the heavy lifting of finding and executing the appropriate handlers for any given webhook.

The `@hook` decorator takes in four parameters, the `WebhookEvent`, a list of `WebhookEventAction`s, an optional list of repositories and a `debug` flag (defaults to `False`).
The `@hook` decorator takes in four parameters, the `WebhookEvent`, a list of `WebhookEventAction`s, an optional list of repositories and a `debug` flag (defaults to `False`).

Any function this decorator is applied to is invoked whenever you receive an event with the specified `WebhookEvent` and a listed `WebhookEventAction`.

Expand All @@ -82,7 +82,7 @@ def work(event: PullRequestEvent):

`work()` is automatically called with the parsed `PullRequestEvent` anytime you receive a webhook event with `X-Github-Event: pull_request` and it has any of the `created` or `edited` actions.

If you don't specify a list of actions, then the function is invoked for _any_ action. For some events like `Push`, which do not have an `action`, take care not to specify any actions in the decorator.
If you don't specify a list of actions, then the function is invoked for _any_ action. For some events like `Push`, which do not have an `action`, take care not to specify any actions in the decorator.

#### hooks/do_something.py
```python
Expand All @@ -105,37 +105,39 @@ import octohook

app = Flask(__name__)

octohook.load_hooks(["hooks"])
# Load webhook handlers from the hooks module
octohook.setup(modules=["hooks"])

@app.route('/webhook', methods=['POST'])
def webhook():
github_event = request.headers.get('X-GitHub-Event')

octohook.handle_webhook(event_name=github_event, payload=request.json)

return Response("OK", status=200)
```

`handle_hooks` goes through all the handlers sequentially and blocks till everything is done. Any exceptions are logged to `logging.getLogger('octohook')`. You can configure the output stream of this logger to capture the logs.
`handle_webhook` goes through all the handlers sequentially and blocks till everything is done. Any exceptions are logged to `logging.getLogger('octohook')`. You can configure the output stream of this logger to capture the logs.

### Model Overrides

`octohook` provides a way to extend/modify the models being provided in the event object. `model_overrides` is a dictionary where you can map `octohook` models to your own.

`octohook` provides a way to extend/modify the models being provided in the event object.

```python
import octohook
from octohook.models import PullRequest

class MyPullRequest(PullRequest):

def custom_work(self):
pass

octohook.load_hooks(["module_a"])
octohook.model_overrides = {
PullRequest: MyPullRequest
}
octohook.setup(
modules=["module_a"],
model_overrides={
PullRequest: MyPullRequest
}
)
```

Now, everytime `octohook` attempts to initialize a `PullRequest` object, it will initialize `MyPullRequest` instead.
Expand All @@ -145,5 +147,5 @@ Check the [test](tests/test_model_override.py) for example usage.
**Note**

- The class is initialized with the relevant `payload: dict` data from the incoming event payload.
- It is recommended you subclass the original model class, but it is not required.
- You must subclass the original model class - `setup()` validates this automatically.
- Type hints are no longer reliable for the overridden classes.
101 changes: 86 additions & 15 deletions octohook/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from importlib import import_module
from pathlib import Path
from pkgutil import walk_packages
from typing import List
from typing import List, Optional, Dict, Type

from .decorators import hook, handle_webhook
from .events import parse, WebhookEvent, WebhookEventAction
Expand All @@ -11,26 +11,27 @@
"events",
"handle_webhook",
"hook",
"load_hooks",
"models",
"model_overrides",
"OctohookConfigError",
"parse",
"reset",
"setup",
"WebhookEvent",
"WebhookEventAction",
]

logger = logging.getLogger("octohook")

_imported_modules = []
model_overrides = {}
_model_overrides = {}

def _import_module(module: str) -> List[str]:
try:
imported = import_module(module)
except Exception as e:
logger.error("Failed to import module %s", module, exc_info=e)
return []

class OctohookConfigError(Exception):
"""Raised when octohook configuration is invalid."""
pass

def _import_module(module: str) -> List[str]:
imported = import_module(module)
module_path = imported.__file__

if module_path.endswith("__init__.py"):
Expand All @@ -47,14 +48,84 @@ def _import_module(module: str) -> List[str]:
imported_modules.append(import_module(module_to_be_imported).__name__)
return imported_modules

def load_hooks(modules: List[str]):

def setup(
*,
modules: List[str],
model_overrides: Optional[Dict[Type, Type]] = None,
) -> None:
"""
Iterates through the given modules recursively and imports all the modules to load hook information.
Configure octohook by loading webhook handlers and registering model overrides.

This function clears any existing configuration via reset(), then recursively
imports the specified modules to discover and register all decorated webhook
handlers.

@param modules List of modules to be imported. The modules cannot be relative. For example, you may use
"module_a.module_b" or "module_a", but not ".module_a" or "module_a/module_b"
Args:
modules: List of fully-qualified module paths containing webhook handlers.
Modules are imported recursively. Cannot use relative imports.
model_overrides: Optional mapping of base model classes to custom subclasses.
All custom classes are validated to ensure they inherit from
the base class they override.

Raises:
ImportError: If any specified module cannot be imported.
TypeError: If a model override is not a class or not a subclass of the base model.

Example:
>>> import octohook
>>> from octohook.models import PullRequest
>>>
>>> class CustomPullRequest(PullRequest):
... pass
>>>
>>> octohook.setup(
... modules=["hooks.github", "hooks.slack"],
... model_overrides={PullRequest: CustomPullRequest}
... )
"""
global _imported_modules
global _imported_modules, _model_overrides

reset()

if model_overrides:
for base_class, override_class in model_overrides.items():
if not isinstance(override_class, type):
raise TypeError(
f"Model override for {base_class.__name__} must be a class, "
f"got {type(override_class).__name__}"
)
if not issubclass(override_class, base_class):
raise TypeError(
f"Model override {override_class.__name__} must be a subclass of "
f"{base_class.__name__}"
)

_model_overrides = model_overrides.copy()

for module in modules:
_imported_modules.extend(_import_module(module))


def reset() -> None:
"""
Clear all octohook configuration and return to unconfigured state.

Removes all registered webhook handlers, clears the list of imported modules,
and removes all model overrides. After calling reset(), setup() must be called
again before handling webhooks.

This function is automatically called by setup() to ensure a clean configuration.
It can also be called directly to clear octohook state.

Example:
>>> import octohook
>>> octohook.reset()
>>> octohook.setup(modules=["hooks"])
"""
global _imported_modules, _model_overrides
from octohook.decorators import _decorator

_decorator.handlers.clear()
_imported_modules.clear()
_model_overrides.clear()
4 changes: 2 additions & 2 deletions octohook/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ def _transform(url: str, local_variables: dict) -> str:

class BaseGithubModel(ABC):
def __new__(cls, *args, **kwargs):
from octohook import model_overrides
from octohook import _model_overrides

cls = model_overrides.get(cls) or cls
cls = _model_overrides.get(cls) or cls
return object.__new__(cls)


Expand Down
21 changes: 21 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,30 @@
from typing import List, Dict, Any

import pytest
import octohook
from octohook.decorators import _WebhookDecorator


@pytest.fixture(autouse=True)
def cleanup_test_modules():
"""
Clean up test module imports after each test.

Removes test hook modules from sys.modules to ensure decorators re-register
on each test. This is necessary because @hook decorators execute at import time,
and without cleanup, Python won't re-execute them when modules are imported again.
"""
import sys

original_modules = set(sys.modules.keys())

yield

for module_name in list(sys.modules.keys()):
if module_name not in original_modules and module_name.startswith("tests.hooks"):
sys.modules.pop(module_name, None)


@pytest.fixture
def isolated_decorator():
"""
Expand Down
Loading