{"id":49628376,"url":"https://github.com/danieliser/python-async-hooks","last_synced_at":"2026-05-05T09:05:10.889Z","repository":{"id":345683124,"uuid":"1174262190","full_name":"danieliser/python-async-hooks","owner":"danieliser","description":"WordPress-style async hooks and filters for Python — add_action, do_action, add_filter, apply_filters","archived":false,"fork":false,"pushed_at":"2026-03-20T06:34:50.000Z","size":138,"stargazers_count":0,"open_issues_count":5,"forks_count":1,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-03-20T23:00:06.757Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/danieliser.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-06T08:42:29.000Z","updated_at":"2026-03-20T06:34:53.000Z","dependencies_parsed_at":"2026-03-20T23:00:18.882Z","dependency_job_id":null,"html_url":"https://github.com/danieliser/python-async-hooks","commit_stats":null,"previous_names":["danieliser/python-async-hooks"],"tags_count":3,"template":false,"template_full_name":null,"purl":"pkg:github/danieliser/python-async-hooks","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/danieliser%2Fpython-async-hooks","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/danieliser%2Fpython-async-hooks/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/danieliser%2Fpython-async-hooks/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/danieliser%2Fpython-async-hooks/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/danieliser","download_url":"https://codeload.github.com/danieliser/python-async-hooks/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/danieliser%2Fpython-async-hooks/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32642312,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-04T10:08:07.713Z","status":"online","status_checked_at":"2026-05-05T02:00:06.033Z","response_time":54,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-05-05T09:05:00.669Z","updated_at":"2026-05-05T09:05:10.872Z","avatar_url":"https://github.com/danieliser.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# python-async-hooks\n\nWordPress-style async hooks and filters for Python — `add_action`, `do_action`, `add_filter`, `apply_filters` — with priority ordering, re-entrancy safety, mixed sync/async callbacks, execution scopes, wildcard subscriptions, namespace support, introspection, and optional typed payload validation.\n\n## Why\n\nIf you've built anything in WordPress, you know the power of its hooks system: components register interest in events without knowing about each other, and everything composes cleanly. This package brings that pattern to async Python.\n\nInstead of monolithic `if/elif` dispatch blocks, each module registers its own handlers at startup. The core never needs to know what's listening.\n\n## Install\n\n```bash\npip install python-async-hooks\n```\n\nRequires Python 3.10+. Zero required dependencies. Install `pydantic` to use typed payload validation.\n\n## Quick Start\n\n```python\nimport asyncio\nfrom async_hooks import AsyncHooks\n\nhooks = AsyncHooks()\n\n# Actions — fire-and-forget observers\nhooks.on(\"request.received\", lambda req: print(f\"Got request: {req}\"))\n\nasync def log_request(req):\n    await asyncio.sleep(0)\n    print(f\"Logged: {req}\")\n\nhooks.on(\"request.received\", log_request, priority=5)  # runs first\n\nawait hooks.do_action(\"request.received\", {\"path\": \"/api/tools\"})\n\n\n# Filters — transform a value through a chain\nhooks.intercept(\"active_tools\", lambda tools: [t for t in tools if t.get(\"enabled\")])\n\nasync def inject_context_tools(tools, context):\n    if context.get(\"role\") == \"admin\":\n        tools.append({\"name\": \"debug\", \"enabled\": True})\n    return tools\n\nhooks.intercept(\"active_tools\", inject_context_tools, accepted_args=2)\n\ntools = await hooks.apply_filters(\"active_tools\", all_tools, {\"role\": \"admin\"})\n```\n\n## API\n\n### Actions\n\n```python\n# Register — two equivalent spellings\ncallback_id = hooks.on(hook_name, callback, priority=10, timeout_seconds=None, detach=False)\ncallback_id = hooks.add_action(hook_name, callback, priority=10, timeout_seconds=None, detach=False)\n\n# Fire\nawait hooks.do_action(hook_name, *args, **kwargs)\n\n# Remove\nhooks.off(hook_name, callback_id)             # universal removal (works for actions and filters)\nhooks.remove_action(hook_name, callback_id)\nhooks.remove_all_actions(hook_name, priority=None)\n\n# Inspect\nhooks.has_action(hook_name)           # → count of callbacks\nhooks.has_action(hook_name, cb_id)    # → bool\nhooks.doing_action(hook_name)         # → bool (currently executing)\nhooks.did_action(hook_name)           # → int (total executions)\n```\n\n### Filters\n\n```python\n# Register — two equivalent spellings\ncallback_id = hooks.intercept(hook_name, callback, priority=10, accepted_args=1, timeout_seconds=None)\ncallback_id = hooks.add_filter(hook_name, callback, priority=10, accepted_args=1, timeout_seconds=None)\n\n# Apply\nresult = await hooks.apply_filters(hook_name, value, *args, **kwargs)\n\n# Remove\nhooks.off(hook_name, callback_id)             # universal removal\nhooks.remove_filter(hook_name, callback_id)\nhooks.remove_all_filters(hook_name, priority=None)\n\n# Inspect\nhooks.has_filter(hook_name)\nhooks.has_filter(hook_name, cb_id)\nhooks.doing_filter(hook_name)\nhooks.did_filter(hook_name)\n```\n\n`on()` / `intercept()` are ergonomic aliases that make intent explicit: `on()` = observer (return value ignored), `intercept()` = transformer (must return the value).\n\n### Wildcard Subscriptions\n\nSubscribe to every event, or every event within a namespace:\n\n```python\n# Fires for every do_action() and apply_filters() call\ncallback_id = hooks.subscribe_all(handler, priority=90)\n\n# Fires only for hooks matching \"task\" or \"task.*\"\ncallback_id = hooks.subscribe_all(handler, namespace=\"task\")\n\nhooks.unsubscribe_all(callback_id)\nhooks.has_global(callback_id)  # → bool\n```\n\nGlobal handler signature — event name is always the first argument:\n\n```python\nasync def audit_handler(event_name: str, *args, **kwargs) -\u003e None:\n    ...\n```\n\nFor filter events, `args[0]` is the post-chain value. Return values from global handlers are always ignored — they're observers, not transformers.\n\nGlobal handlers fire **after** all name-specific callbacks for a given event.\n\n### Namespaces\n\nHook names are dot-delimited by convention (`task.created`, `task.lifecycle.start`). The prefix before the first dot is the namespace. Several APIs accept a `namespace` argument for scoped operations:\n\n```python\n# Scoped wildcard — fires for task.*, not config.*\nhooks.subscribe_all(handler, namespace=\"task\")\n\n# Filter event catalog to a namespace\nhooks.registered_events(namespace=\"task\")\n# → {\"task.created\", \"task.completed\", \"task.dispatch\"}\n\n# Filter introspection to a namespace\nhooks.describe_all(namespace=\"task\")\n\n# Remove all callbacks across every hook in a namespace — useful for plugin teardown\nhooks.remove_namespace(\"task\")  # → int (number of hooks cleared)\n```\n\nNamespace matching is exact-prefix only: `\"task\"` matches `\"task\"` and `\"task.created\"` but **not** `\"taskrunner.start\"`.\n\n**Plugin pattern:**\n\n```python\nclass MyPlugin:\n    NAMESPACE = \"my_plugin\"\n\n    def register(self, hooks):\n        hooks.on(f\"{self.NAMESPACE}.task.created\", self.handle_task)\n        hooks.subscribe_all(self.audit, namespace=self.NAMESPACE)\n\n    def unregister(self, hooks):\n        hooks.remove_namespace(self.NAMESPACE)\n```\n\n### Introspection\n\n```python\n# All registered hook names\nhooks.registered_events()                    # → set[str]\nhooks.registered_events(namespace=\"task\")   # → set[str], filtered\n\n# Ordered list of HandlerInfo dicts for a hook\nhooks.describe(\"task.created\")\n# → [{\"callback_id\": \"...\", \"hook_type\": \"action\", \"priority\": 10,\n#      \"handler_name\": \"handle_task\", \"module\": \"my_plugin.tasks\",\n#      \"detached\": False, \"accepted_args\": 1}, ...]\n\n# All hooks combined, sorted by hook name\nhooks.describe_all()\nhooks.describe_all(namespace=\"task\")\n```\n\n`HandlerInfo` is a `TypedDict` exported from the package root.\n\n### Typed Payload Validation\n\nRegister a Pydantic model as the expected payload contract for a hook. Validation runs at emit time, before any callbacks are called:\n\n```python\nfrom pydantic import BaseModel\n\nclass TaskPayload(BaseModel):\n    task_id: str\n    priority: int = 10\n\n# Opt-in at construction or toggle at runtime\nhooks = AsyncHooks(validate_payloads=True)\nhooks.register_schema(\"task.created\", TaskPayload)\nhooks.schema_for(\"task.created\")   # → TaskPayload\n\n# Valid payload — passes through\nawait hooks.do_action(\"task.created\", {\"task_id\": \"t1\", \"priority\": 5})\n\n# Invalid payload — raises HookPayloadError before any callback fires\nawait hooks.do_action(\"task.created\", {\"wrong_field\": \"x\"})\n```\n\n`validate_payloads` defaults to `False` (zero overhead in production). Toggle at runtime: `hooks.validate_payloads = True`. Pydantic is an optional dependency — only required if you call `register_schema()`.\n\n`HookPayloadError` is exported from the package root and carries `hook_name`, `schema`, and `errors` attributes.\n\n### Scopes\n\nScopes provide execution context that callbacks can read without explicit argument passing. Backed by `contextvars` — safe across concurrent tasks:\n\n```python\nasync with hooks.scope(\"request\", user_id=42, tenant=\"acme\") as scope:\n    await hooks.do_action(\"request.start\")\n    # callbacks can call hooks.current_scope to read metadata\n```\n\nScopes nest — inner scopes expose their parent.\n\n### Detached Listeners\n\nFire a callback as an independent `asyncio.Task` without blocking the caller:\n\n```python\nhooks.on(\"task.dispatched\", heavy_work, detach=True)\n# do_action returns immediately; heavy_work runs in the background\n```\n\nDetached listeners are fully isolated — one failing doesn't affect others.\n\n## Behavior\n\n- **Priority**: lower number = higher priority. Default is 10. Same-priority callbacks run in registration order.\n- **Mixed sync/async**: sync and async callbacks coexist on the same hook.\n- **Re-entrancy**: hooks can fire themselves recursively. Removals during execution are deferred until the hook completes.\n- **Timeouts**: actions default to 30s per callback. Filters have no default timeout. Both are configurable per-manager or per-callback. Timed-out callbacks log a warning and the chain continues.\n- **Exceptions**: a failing callback logs an error and the chain continues. Hooks never propagate listener exceptions to the caller.\n- **`accepted_args`**: filter callbacks declare how many positional args they accept (including the filtered value). Extra args are trimmed automatically.\n\n## Use Case: Plugin Architecture\n\n```python\n# core.py — knows nothing about specific plugins\ntools = await hooks.apply_filters(\"active_tools\", [], context)\n\n# search_plugin.py\nclass SearchPlugin:\n    NAMESPACE = \"search\"\n\n    def register(self, hooks):\n        hooks.intercept(\"active_tools\", self.inject_tools, accepted_args=2)\n        hooks.subscribe_all(self.trace, namespace=self.NAMESPACE)\n\n    def unregister(self, hooks):\n        hooks.remove_namespace(self.NAMESPACE)\n\n    async def inject_tools(self, tools, ctx):\n        return tools + SEARCH_TOOLS\n\n    async def trace(self, event_name, *args, **kwargs):\n        logger.debug(\"search plugin event: %s\", event_name)\n```\n\nEach plugin owns its namespace. Teardown is one call.\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdanieliser%2Fpython-async-hooks","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fdanieliser%2Fpython-async-hooks","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdanieliser%2Fpython-async-hooks/lists"}