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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,4 @@ environment.yml
_
/docs/build/
/_build/
/.claude/
8 changes: 8 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ UNRELEASED
==========

- Added full type annotations and exported types (via ``py.typed``).
- Suppress the Windows hard-error dialog that ``LoadLibraryW`` would raise when a plugin
DLL has unresolved imports (e.g. a conflicting dependency version bundled inside the
plugin). The OS error is now captured and raised as the new ``SharedLibraryLoadError``
exception instead of blocking the process with a modal dialog.
- Plugin discovery is now resilient to individual load failures: ``get_plugins_available``
skips broken plugins with a warning, and the new ``get_plugins_available_and_failures``
method returns the successful plugins together with a list of ``PluginLoadFailure``
records so callers can surface the reason to the user.

0.8.0 (2025-08-18)
==================
Expand Down
76 changes: 76 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# hookman — Developer Tooling Reference

## Environment setup

This project uses a plain virtualenv (not pixi, despite the presence of `pixi.devenv.toml`).

```bash
python -m venv .venv

# Windows
.venv/Scripts/pip install -e . -r requirements_dev.txt

# Linux
.venv/bin/pip install -e . -r requirements_dev.txt
```

## Type checking

```bash
prek run mypy -a
```

## Running tests

```bash
# Windows
.venv/Scripts/pytest tests/test_hooks.py tests/test_hookman_utils.py -v

# Linux
.venv/bin/pytest tests/test_hooks.py tests/test_hookman_utils.py -v
```

Some tests require compiled C++ artifacts (see **Building** below). The
`add_artifacts_to_path` autouse fixture in `conftest.py` adds `build/artifacts`
to `sys.path` automatically, so no `PYTHONPATH` setup is needed.

## Building C++ artifacts

```bash
# Windows
.venv/Scripts/inv build

# Linux
.venv/bin/inv build
```

This compiles the test plugins (`.dll`/`.so`) and the pybind11 binding
(`.pyd`/`.so`), installing them to `build/artifacts/`. Must be re-run whenever
C++ source files change.

On Windows, requires Visual Studio with MSVC. On Linux, requires a C++ compiler
(e.g. `g++`) and `ninja`.

## Regenerating file regression snapshots

Some tests in `test_hookman_generator.py` use `pytest-regressions` to compare
generated files against stored snapshots. When the generator output changes
intentionally, regenerate the snapshots with:

```bash
# Windows
.venv/Scripts/pytest tests/test_hookman_generator.py --force-regen

# Linux
.venv/bin/pytest tests/test_hookman_generator.py --force-regen
```

Then verify the regenerated files look correct before committing them.

## Pre-commit / linting

```bash
prek run --all-files
```

This runs mypy, ruff, and other hooks defined in `.pre-commit-config.yaml`.
3 changes: 0 additions & 3 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,3 @@ exclude = docs

[aliases]
test = pytest

[tool:pytest]
collect_ignore = ['setup.py']
21 changes: 21 additions & 0 deletions src/hookman/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from pathlib import Path


class HookmanError(Exception):
"""
Base class for all hookman exceptions.
Expand All @@ -11,6 +14,24 @@ class SharedLibraryNotFoundError(HookmanError):
"""


class SharedLibraryLoadError(HookmanError):
"""
Exception raised when a shared library exists but fails to load.

This typically occurs when the plugin DLL has unresolved imports or
incompatible entry points — for example, when a plugin bundles a
conflicting version of a dependency that the host also provides.

:param shared_lib_path: Path to the shared library that failed to load.
:param reason: Human-readable OS error description.
"""

def __init__(self, shared_lib_path: Path, reason: str) -> None:
self.shared_lib_path = shared_lib_path
self.reason = reason
super().__init__(f"Failed to load '{shared_lib_path}': {reason}")


class InvalidDestinationPathError(HookmanError):
"""
Exception raised when the destination path to install the plugin is not one of the paths used
Expand Down
22 changes: 19 additions & 3 deletions src/hookman/hookman_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -856,7 +856,12 @@ def _generate_windows_body(hooks: list[Hook]) -> list[str]:
" std::wstring w_filename = utf8_to_wstring(utf8_filename);",
" auto handle = this->load_dll(w_filename);",
" if (handle == NULL) {",
' throw std::runtime_error("Error loading library " + utf8_filename + ": " + std::to_string(GetLastError()));',
" DWORD error_code = GetLastError();",
" char error_buf[512] = {};",
" FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, nullptr, error_code, 0, error_buf, sizeof(error_buf), nullptr);",
" std::string error_msg(error_buf);",
" while (!error_msg.empty() && (error_msg.back() <= ' ')) { error_msg.pop_back(); }",
' throw std::runtime_error("Error loading library " + utf8_filename + ": " + error_msg + " (code " + std::to_string(error_code) + ")");',
" }",
" this->handles.push_back(handle);",
"",
Expand Down Expand Up @@ -929,8 +934,19 @@ def _generate_windows_body(hooks: list[Hook]) -> list[str]:
" HMODULE load_dll(const std::wstring& filename) {",
" // Path Modifier",
" PathGuard path_guard{ filename };",
" // Load library (DLL)",
" return LoadLibraryW(filename.c_str());",
" // Suppress the Windows hard-error dialog that LoadLibraryW would otherwise",
" // show for unresolved DLL imports before returning NULL.",
" // NOTE: the same suppression logic exists in suppress_dll_error_dialog() in",
" // hookman_utils.py - keep both in sync when changing flags or error handling.",
" DWORD old_error_mode = 0;",
" if (!SetThreadErrorMode(SEM_FAILCRITICALERRORS | SEM_NOOPENFILEERRORBOX, &old_error_mode)) {",
' throw std::runtime_error("SetThreadErrorMode failed: " + std::to_string(GetLastError()));',
" }",
" HMODULE handle = LoadLibraryW(filename.c_str());",
" if (!SetThreadErrorMode(old_error_mode, nullptr)) {",
' throw std::runtime_error("SetThreadErrorMode restore failed: " + std::to_string(GetLastError()));',
" }",
" return handle;",
" }",
"",
"",
Expand Down
57 changes: 56 additions & 1 deletion src/hookman/hookman_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from contextlib import contextmanager
from pathlib import Path

from hookman.exceptions import SharedLibraryLoadError


def find_config_files(
plugin_dirs: Sequence[Path] | Path, *, ignored_sub_dir_names: Sequence[str] = ()
Expand Down Expand Up @@ -52,13 +54,66 @@ def change_path_env(shared_lib_path: str) -> Iterator[None]:
handle.close() # pragma: no cover


@contextmanager
def suppress_dll_error_dialog() -> Iterator[None]: # pragma: no cover
"""
Suppress the Windows hard-error dialog that the OS loader shows when a
DLL fails to resolve its imports or entry points.

No-op on non-Windows platforms.

The same suppression logic is duplicated in the C++ `load_dll` function
generated by `hookman_generator.py` — keep both in sync when changing
the error-mode flags or the error-handling strategy.
"""
if sys.platform != "win32":
yield
return

# Without this, `LoadLibraryW` raises a modal dialog for certain load
# failures (missing procedure, missing module) that blocks the process until
# manually dismissed. Setting `SEM_FAILCRITICALERRORS | SEM_NOOPENFILEERRORBOX`
# via `SetThreadErrorMode` makes the loader return NULL to the caller instead,
# so the error is handled programmatically via the normal `OSError`/`GetLastError`
# path.
SEM_FAILCRITICALERRORS = 0x0001
SEM_NOOPENFILEERRORBOX = 0x8000
# ctypes.WinDLL is Windows-only; mypy on non-Windows platforms does not resolve this attribute.
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) # type: ignore[attr-defined]
old_mode = ctypes.c_uint(0)
# The thread-local `SetThreadErrorMode` is used rather than the process-wide
# `SetErrorMode` to avoid interfering with other threads.
if not kernel32.SetThreadErrorMode(
SEM_FAILCRITICALERRORS | SEM_NOOPENFILEERRORBOX,
ctypes.byref(old_mode),
):
raise OSError(ctypes.get_last_error(), "SetThreadErrorMode failed")
try:
yield
finally:
if not kernel32.SetThreadErrorMode(old_mode.value, None):
raise OSError(ctypes.get_last_error(), "SetThreadErrorMode restore failed")


@contextmanager
def load_shared_lib(shared_lib_path: str) -> Iterator[ctypes.CDLL]:
"""
Load a shared library using ctypes freeing the resource at end.

On Windows, the OS hard-error dialog for unresolved DLL imports is
suppressed so that load failures surface as `SharedLibraryLoadError`
instead of blocking modal dialogs.

:raises SharedLibraryLoadError:
If the shared library exists but fails to load, e.g. because of an
incompatible or missing dependency DLL.
"""
with change_path_env(shared_lib_path):
plugin_dll = ctypes.cdll.LoadLibrary(shared_lib_path)
with suppress_dll_error_dialog():
try:
plugin_dll = ctypes.cdll.LoadLibrary(shared_lib_path)
except OSError as error:
raise SharedLibraryLoadError(Path(shared_lib_path), str(error)) from error

try:
yield plugin_dll
Expand Down
83 changes: 70 additions & 13 deletions src/hookman/hooks.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import inspect
import logging
import shutil
from collections.abc import Callable
from collections.abc import Sequence
Expand All @@ -12,9 +13,13 @@
from hookman import hookman_utils
from hookman.exceptions import InvalidDestinationPathError
from hookman.exceptions import PluginAlreadyInstalledError
from hookman.exceptions import SharedLibraryLoadError
from hookman.exceptions import SharedLibraryNotFoundError
from hookman.hookman_utils import change_path_env
from hookman.plugin_config import PluginInfo

_logger = logging.getLogger(__name__)


@dataclass(frozen=True)
class InstalledPluginInfo:
Expand All @@ -26,6 +31,20 @@ class InstalledPluginInfo:
version: Version


@dataclass(frozen=True)
class PluginLoadFailure:
"""Information about a plugin that was found but failed to load during discovery."""

yaml_location: Path
"""Path to the plugin's plugin.yaml file."""

plugin_id: str
"""The plugin id, read from the plugin's YAML config file."""

reason: str
"""Human-readable description of why the plugin failed to load."""


class HookSpecs:
"""
A class that holds the specification of the hooks, currently the following specification are available:
Expand Down Expand Up @@ -203,6 +222,49 @@ def remove_plugin(self, caption: str, version: Version | None = None) -> None:
self._try_clear_trash(root_dir)
break

def get_plugins_available_and_failures(
self, ignored_plugins: Sequence[str] = ()
) -> tuple[list[PluginInfo], list[PluginLoadFailure]]:
"""
Return all plugins that loaded successfully, plus a list of those that failed.

A single incompatible plugin DLL will not abort discovery of the remaining
plugins — `SharedLibraryLoadError` is caught per-plugin and reported in the
failures list instead.

Optionally you can pass a list of plugin ids to exclude from both lists.

:returns:
A tuple of (successful `PluginInfo` list, `PluginLoadFailure` list).
"""
plugin_ids_and_files = [
(plugin_id, f)
for f in hookman_utils.find_config_files(
self.plugins_dirs, ignored_sub_dir_names=[self._TRASH_DIR_NAME]
)
if (plugin_id := PluginInfo.parse_id(f)) not in ignored_plugins
]

plugins: list[PluginInfo] = []
failures: list[PluginLoadFailure] = []
for plugin_id, plugin_file in plugin_ids_and_files:
try:
plugin_info = PluginInfo(plugin_file, self.hooks_available)
except (SharedLibraryLoadError, SharedLibraryNotFoundError) as error:
reason = str(error)
_logger.warning("Plugin at '%s' failed to load: %s", plugin_file, reason)
failures.append(
PluginLoadFailure(
yaml_location=plugin_file,
plugin_id=plugin_id,
reason=reason,
)
)
continue
else:
plugins.append(plugin_info)
return plugins, failures

def get_plugins_available(self, ignored_plugins: Sequence[str] = ()) -> Sequence[PluginInfo]:
"""
Return a list of :ref:`plugin-info-api-section` that are available on ``plugins_dirs``
Expand All @@ -211,20 +273,11 @@ def get_plugins_available(self, ignored_plugins: Sequence[str] = ()) -> Sequence
When informed, the `ignored_plugins` must be a list with the names of the plugins (same as shared_lib_name)
instead of the plugin caption.

The :ref:`plugin-info-api-section` is a object that holds all information related to the plugin.
Plugins whose DLL fails to load are silently skipped with a warning logged.
Use `get_plugins_available_and_failures` to receive the failure details.
"""
plugin_config_files = hookman_utils.find_config_files(
self.plugins_dirs, ignored_sub_dir_names=[self._TRASH_DIR_NAME]
)

plugins_available = [
PluginInfo(plugin_file, self.hooks_available) for plugin_file in plugin_config_files
]
return [
plugin_info
for plugin_info in plugins_available
if plugin_info.id not in ignored_plugins
]
plugins, _failures = self.get_plugins_available_and_failures(ignored_plugins)
return plugins

def get_hook_caller(self, ignored_plugins: Sequence[str] = ()) -> HookCaller:
Comment thread
nicoddemus marked this conversation as resolved.
"""
Expand All @@ -233,6 +286,10 @@ def get_hook_caller(self, ignored_plugins: Sequence[str] = ()) -> HookCaller:

When informed, the `ignored_plugins` must be a list with the names of the plugins (same as shared_lib_name)
instead of the plugin caption.

Plugins whose DLL fails to load are silently skipped with a warning logged — they
do not raise an exception here. Use `get_plugins_available_and_failures` to
inspect load failures.
"""
assert self.specs.pyd_name is not None, f"Specs {self.specs!r}.pyd_name must be set"
_hookman = __import__(self.specs.pyd_name)
Expand Down
6 changes: 6 additions & 0 deletions src/hookman/plugin_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ def _get_hooks_implemented(self) -> Sequence[str]:
]
return hooks_implemented

@classmethod
def parse_id(cls, yaml_location: Path) -> str:
"""Read the plugin id from the YAML config file without loading the shared library."""
content = cls._load_yaml_file(yaml_location.read_text(encoding="utf-8"))
return str(content["id"])

@classmethod
def is_implemented_on_plugin(cls, plugin_dll: ctypes.CDLL, hook_name: str) -> bool:
"""
Expand Down
Loading
Loading