taskmaestro 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,57 @@
1
+ """Workflow Runner: typed DAG task workflows with Pydantic models."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from taskmaestro.context import ExecutionContext
6
+ from taskmaestro.exceptions import (
7
+ ConfigLoadError,
8
+ CycleDetectedError,
9
+ IncompleteInputError,
10
+ JobStateError,
11
+ TaskExecutionError,
12
+ TaskOutputTypeError,
13
+ TaskTimeoutError,
14
+ WorkflowDefinitionError,
15
+ WorkflowRunnerError,
16
+ )
17
+ from taskmaestro.job import EmptyConfig, Job, JobConfiguration, JobStatus, TaskResult, TaskStatus
18
+ from taskmaestro.object_model import ObjectModel
19
+ from taskmaestro.runner import Runner
20
+ from taskmaestro.task import Task
21
+ from taskmaestro.visualization import to_mermaid
22
+ from taskmaestro.workflow import Workflow, WorkflowBuilder
23
+ from taskmaestro.workflow_task import workflow_task
24
+ from taskmaestro.yaml_config import (
25
+ LoadedWorkflow,
26
+ load_workflow_from_yaml,
27
+ run_workflow_from_yaml,
28
+ )
29
+
30
+ __all__ = [
31
+ "ConfigLoadError",
32
+ "CycleDetectedError",
33
+ "EmptyConfig",
34
+ "ExecutionContext",
35
+ "IncompleteInputError",
36
+ "Job",
37
+ "JobConfiguration",
38
+ "JobStateError",
39
+ "JobStatus",
40
+ "LoadedWorkflow",
41
+ "ObjectModel",
42
+ "Runner",
43
+ "Task",
44
+ "TaskExecutionError",
45
+ "TaskOutputTypeError",
46
+ "TaskResult",
47
+ "TaskStatus",
48
+ "TaskTimeoutError",
49
+ "Workflow",
50
+ "WorkflowBuilder",
51
+ "WorkflowDefinitionError",
52
+ "WorkflowRunnerError",
53
+ "load_workflow_from_yaml",
54
+ "run_workflow_from_yaml",
55
+ "to_mermaid",
56
+ "workflow_task",
57
+ ]
taskmaestro/context.py ADDED
@@ -0,0 +1,37 @@
1
+ """Execution context passed to every task during execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import tempfile
7
+ import uuid
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ class ExecutionContext:
13
+ """Cross-cutting state passed to every task during execution.
14
+
15
+ Carries a correlation ID, logger, scratch directory, and a simple
16
+ service registry for injecting dependencies (DB connections, HTTP
17
+ clients, etc.) into tasks.
18
+ """
19
+
20
+ def __init__(
21
+ self,
22
+ correlation_id: str | None = None,
23
+ logger: logging.Logger | None = None,
24
+ scratch_dir: Path | None = None,
25
+ ) -> None:
26
+ self.correlation_id = correlation_id or str(uuid.uuid4())
27
+ self.logger = logger or logging.getLogger("taskmaestro")
28
+ self.scratch_dir = scratch_dir or Path(tempfile.gettempdir()) / self.correlation_id
29
+ self._registry: dict[str, Any] = {}
30
+
31
+ def register(self, key: str, service: Any) -> None:
32
+ """Register a service by key."""
33
+ self._registry[key] = service
34
+
35
+ def resolve(self, key: str) -> Any:
36
+ """Retrieve a registered service. Raises KeyError if not found."""
37
+ return self._registry[key]
@@ -0,0 +1,37 @@
1
+ """Exception hierarchy for the workflow runner library."""
2
+
3
+
4
+ class WorkflowRunnerError(Exception):
5
+ """Base exception for all workflow runner errors."""
6
+
7
+
8
+ class WorkflowDefinitionError(WorkflowRunnerError):
9
+ """Raised at workflow construction time for invalid definitions."""
10
+
11
+
12
+ class CycleDetectedError(WorkflowDefinitionError):
13
+ """Dependency graph contains a cycle."""
14
+
15
+
16
+ class IncompleteInputError(WorkflowDefinitionError):
17
+ """Fan-in input has required fields not mapped to any upstream task."""
18
+
19
+
20
+ class JobStateError(WorkflowRunnerError):
21
+ """Job not in expected state (e.g., attempting to re-run a completed job)."""
22
+
23
+
24
+ class TaskExecutionError(WorkflowRunnerError):
25
+ """Raised during task execution."""
26
+
27
+
28
+ class TaskOutputTypeError(TaskExecutionError):
29
+ """Task returned an output whose type doesn't match the declared output type."""
30
+
31
+
32
+ class TaskTimeoutError(TaskExecutionError):
33
+ """Raised when a task exceeds its timeout_seconds."""
34
+
35
+
36
+ class ConfigLoadError(WorkflowRunnerError):
37
+ """Raised when YAML config loading fails (parse errors, import failures, validation)."""
@@ -0,0 +1,15 @@
1
+ """Built-in hooks for the workflow runner."""
2
+
3
+ from taskmaestro.hooks.base import BaseHook, Event, Hook
4
+ from taskmaestro.hooks.logging import LoggingHook
5
+ from taskmaestro.hooks.persistence import ResultPersistenceHook
6
+ from taskmaestro.hooks.timing import TimingHook
7
+
8
+ __all__ = [
9
+ "BaseHook",
10
+ "Event",
11
+ "Hook",
12
+ "LoggingHook",
13
+ "ResultPersistenceHook",
14
+ "TimingHook",
15
+ ]
@@ -0,0 +1,57 @@
1
+ """Hook protocol and base implementation for lifecycle events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
7
+
8
+ if TYPE_CHECKING:
9
+ from pydantic import BaseModel
10
+
11
+ from taskmaestro.job import Job
12
+ from taskmaestro.task import Task
13
+
14
+
15
+ class Event(StrEnum):
16
+ """Lifecycle events emitted by the runner."""
17
+
18
+ JOB_START = "job_start"
19
+ JOB_COMPLETE = "job_complete"
20
+ JOB_FAIL = "job_fail"
21
+ TASK_START = "task_start"
22
+ TASK_COMPLETE = "task_complete"
23
+ TASK_FAIL = "task_fail"
24
+
25
+
26
+ @runtime_checkable
27
+ class Hook(Protocol):
28
+ """Protocol for lifecycle event hooks."""
29
+
30
+ def on_job_start(self, job: Job[Any]) -> None: ...
31
+ def on_job_complete(self, job: Job[Any]) -> None: ...
32
+ def on_job_fail(self, job: Job[Any]) -> None: ...
33
+ def on_task_start(self, job: Job[Any], task: Task[Any, Any]) -> None: ...
34
+ def on_task_complete(self, job: Job[Any], task: Task[Any, Any], output: BaseModel) -> None: ...
35
+ def on_task_fail(self, job: Job[Any], task: Task[Any, Any], error: Exception) -> None: ...
36
+
37
+
38
+ class BaseHook:
39
+ """Base hook with no-op defaults for all events."""
40
+
41
+ def on_job_start(self, job: Job[Any]) -> None:
42
+ pass
43
+
44
+ def on_job_complete(self, job: Job[Any]) -> None:
45
+ pass
46
+
47
+ def on_job_fail(self, job: Job[Any]) -> None:
48
+ pass
49
+
50
+ def on_task_start(self, job: Job[Any], task: Task[Any, Any]) -> None:
51
+ pass
52
+
53
+ def on_task_complete(self, job: Job[Any], task: Task[Any, Any], output: BaseModel) -> None:
54
+ pass
55
+
56
+ def on_task_fail(self, job: Job[Any], task: Task[Any, Any], error: Exception) -> None:
57
+ pass
@@ -0,0 +1,44 @@
1
+ """Logging hook that logs lifecycle events via Python logging."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from taskmaestro.hooks.base import BaseHook
11
+ from taskmaestro.job import Job
12
+ from taskmaestro.task import Task
13
+
14
+
15
+ class LoggingHook(BaseHook):
16
+ """Logs all lifecycle events via Python's logging module."""
17
+
18
+ def __init__(self, level: int = logging.INFO) -> None:
19
+ self._level = level
20
+ self._logger = logging.getLogger("taskmaestro.hooks.logging")
21
+
22
+ def on_job_start(self, job: Job[Any]) -> None:
23
+ self._logger.log(self._level, "Job started: workflow=%s", job.workflow.name)
24
+
25
+ def on_job_complete(self, job: Job[Any]) -> None:
26
+ self._logger.log(self._level, "Job completed: workflow=%s", job.workflow.name)
27
+
28
+ def on_job_fail(self, job: Job[Any]) -> None:
29
+ self._logger.log(
30
+ self._level,
31
+ "Job failed: workflow=%s, failed_task=%s, error=%s",
32
+ job.workflow.name,
33
+ job.failed_task,
34
+ job.error,
35
+ )
36
+
37
+ def on_task_start(self, job: Job[Any], task: Task[Any, Any]) -> None:
38
+ self._logger.log(self._level, "Task started: %s", task.name)
39
+
40
+ def on_task_complete(self, job: Job[Any], task: Task[Any, Any], output: BaseModel) -> None:
41
+ self._logger.log(self._level, "Task completed: %s", task.name)
42
+
43
+ def on_task_fail(self, job: Job[Any], task: Task[Any, Any], error: Exception) -> None:
44
+ self._logger.log(self._level, "Task failed: %s, error=%s", task.name, error)
@@ -0,0 +1,24 @@
1
+ """Result persistence hook that writes task outputs to JSON files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from taskmaestro.hooks.base import BaseHook
11
+ from taskmaestro.job import Job
12
+ from taskmaestro.task import Task
13
+
14
+
15
+ class ResultPersistenceHook(BaseHook):
16
+ """Writes {task_name}.json per completed task to an output directory."""
17
+
18
+ def __init__(self, output_dir: Path) -> None:
19
+ self.output_dir = output_dir
20
+
21
+ def on_task_complete(self, job: Job[Any], task: Task[Any, Any], output: BaseModel) -> None:
22
+ self.output_dir.mkdir(parents=True, exist_ok=True)
23
+ output_path = self.output_dir / f"{task.name}.json"
24
+ output_path.write_text(output.model_dump_json(indent=2))
@@ -0,0 +1,46 @@
1
+ """Timing hook that records wall-clock durations for jobs and tasks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from taskmaestro.hooks.base import BaseHook
11
+ from taskmaestro.job import Job
12
+ from taskmaestro.task import Task
13
+
14
+
15
+ class TimingHook(BaseHook):
16
+ """Records wall-clock duration per task and total job time via time.monotonic()."""
17
+
18
+ def __init__(self) -> None:
19
+ self.job_duration: float | None = None
20
+ self.task_timings: dict[str, float] = {}
21
+ self._job_start: float | None = None
22
+ self._task_starts: dict[str, float] = {}
23
+
24
+ def on_job_start(self, job: Job[Any]) -> None:
25
+ self._job_start = time.monotonic()
26
+
27
+ def on_job_complete(self, job: Job[Any]) -> None:
28
+ if self._job_start is not None:
29
+ self.job_duration = time.monotonic() - self._job_start
30
+
31
+ def on_job_fail(self, job: Job[Any]) -> None:
32
+ if self._job_start is not None:
33
+ self.job_duration = time.monotonic() - self._job_start
34
+
35
+ def on_task_start(self, job: Job[Any], task: Task[Any, Any]) -> None:
36
+ self._task_starts[task.name] = time.monotonic()
37
+
38
+ def on_task_complete(self, job: Job[Any], task: Task[Any, Any], output: BaseModel) -> None:
39
+ start = self._task_starts.get(task.name)
40
+ if start is not None:
41
+ self.task_timings[task.name] = time.monotonic() - start
42
+
43
+ def on_task_fail(self, job: Job[Any], task: Task[Any, Any], error: Exception) -> None:
44
+ start = self._task_starts.get(task.name)
45
+ if start is not None:
46
+ self.task_timings[task.name] = time.monotonic() - start
taskmaestro/job.py ADDED
@@ -0,0 +1,116 @@
1
+ """Job: a workflow bound to a specific config, ready to execute."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from enum import StrEnum
8
+ from typing import Any, Generic, TypeVar
9
+
10
+ from pydantic import BaseModel
11
+
12
+ from taskmaestro.exceptions import WorkflowDefinitionError
13
+ from taskmaestro.task import get_input_type
14
+ from taskmaestro.workflow import Workflow
15
+
16
+
17
+ class JobStatus(StrEnum):
18
+ """Status of a job."""
19
+
20
+ PENDING = "pending"
21
+ RUNNING = "running"
22
+ COMPLETED = "completed"
23
+ FAILED = "failed"
24
+
25
+
26
+ class TaskStatus(StrEnum):
27
+ """Status of an individual task execution."""
28
+
29
+ COMPLETED = "completed"
30
+ FAILED = "failed"
31
+
32
+
33
+ @dataclass
34
+ class TaskResult:
35
+ """Record of a single task's execution within a job."""
36
+
37
+ task_name: str
38
+ status: TaskStatus
39
+ output: BaseModel | None
40
+ started_at: datetime
41
+ duration_seconds: float
42
+ error: str | None = None
43
+
44
+
45
+ class EmptyConfig(BaseModel):
46
+ """Sentinel config for workflows where all root tasks use JobConfiguration."""
47
+
48
+
49
+ class JobConfiguration:
50
+ """Per-task configuration values, mapping task names to config field dicts.
51
+
52
+ Used to provide static configuration values (from YAML or code) that get
53
+ merged with upstream outputs when constructing task inputs.
54
+ """
55
+
56
+ def __init__(self, config: dict[str, dict[str, Any]]) -> None:
57
+ self._config = config
58
+
59
+ def get_config_for_task(self, name: str) -> dict[str, Any]:
60
+ """Return config values for a task, or empty dict if none."""
61
+ return dict(self._config.get(name, {}))
62
+
63
+ def configured_tasks(self) -> set[str]:
64
+ """Return set of task names that have configuration."""
65
+ return set(self._config.keys())
66
+
67
+ def config_fields_for_task(self, name: str) -> set[str]:
68
+ """Return set of field names configured for a task."""
69
+ return set(self._config.get(name, {}).keys())
70
+
71
+
72
+ C = TypeVar("C", bound=BaseModel)
73
+
74
+
75
+ class Job(Generic[C]):
76
+ """A workflow bound to a specific config, ready to execute.
77
+
78
+ Generic over C so that job.config retains its concrete type.
79
+ Validates that the config type matches the input type of all root tasks.
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ workflow: Workflow,
85
+ config: C,
86
+ *,
87
+ job_configuration: JobConfiguration | None = None,
88
+ ) -> None:
89
+ self.workflow = workflow
90
+ self.config: C = config
91
+ self.job_configuration = job_configuration
92
+ self.status: JobStatus = JobStatus.PENDING
93
+ self.result: BaseModel | None = None
94
+ self.error: str | None = None
95
+ self.failed_task: str | None = None
96
+ self.started_at: datetime | None = None
97
+ self.completed_at: datetime | None = None
98
+ self.task_results: list[TaskResult] = []
99
+
100
+ self._validate_root_task_inputs(config)
101
+
102
+ def _validate_root_task_inputs(self, config: C) -> None:
103
+ """Validate that config type matches the input type of all root tasks."""
104
+ for task_name, deps in self.workflow._dependencies.items():
105
+ if deps is None:
106
+ # Skip validation for root tasks that have config_fields
107
+ config_fields = self.workflow.get_config_fields(task_name)
108
+ if config_fields:
109
+ continue
110
+ task_cls = self.workflow._tasks[task_name]
111
+ expected_input = get_input_type(task_cls)
112
+ if not isinstance(config, expected_input):
113
+ raise WorkflowDefinitionError(
114
+ f"Root task '{task_name}' expects input type "
115
+ f"{expected_input.__name__} but got {type(config).__name__}"
116
+ )
@@ -0,0 +1,19 @@
1
+ """Generic base model for wrapping arbitrary (non-Pydantic) objects."""
2
+
3
+ from typing import Generic, TypeVar
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+ T = TypeVar("T")
8
+
9
+
10
+ class ObjectModel(BaseModel, Generic[T]):
11
+ """Base model for wrapping arbitrary (non-Pydantic) objects.
12
+
13
+ Enables arbitrary_types_allowed so fields can hold native library
14
+ objects like database connections, API clients, etc.
15
+ """
16
+
17
+ model_config = ConfigDict(arbitrary_types_allowed=True)
18
+
19
+ value: T
taskmaestro/py.typed ADDED
File without changes
taskmaestro/runner.py ADDED
@@ -0,0 +1,201 @@
1
+ """Runner: the synchronous execution engine for workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import signal
6
+ import warnings
7
+ from datetime import datetime
8
+ from typing import Any
9
+
10
+ from pydantic import BaseModel
11
+
12
+ from taskmaestro.context import ExecutionContext
13
+ from taskmaestro.exceptions import (
14
+ JobStateError,
15
+ TaskOutputTypeError,
16
+ TaskTimeoutError,
17
+ )
18
+ from taskmaestro.hooks.base import BaseHook, Event
19
+ from taskmaestro.job import Job, JobStatus, TaskResult, TaskStatus
20
+ from taskmaestro.task import get_input_type, get_output_type
21
+
22
+
23
+ class Runner:
24
+ """Synchronous execution engine for workflows.
25
+
26
+ Iterates tasks in topological order, assembling each task's input
27
+ from the outputs of its upstream dependencies.
28
+ """
29
+
30
+ def __init__(self, hooks: list[BaseHook] | None = None) -> None:
31
+ self.hooks: list[BaseHook] = hooks or []
32
+
33
+ def run(
34
+ self,
35
+ job: Job[Any],
36
+ ctx: ExecutionContext | None = None,
37
+ timeout_seconds: float | None = None,
38
+ ) -> Job[Any]:
39
+ """Execute all tasks in topological order. Stops on first failure."""
40
+ if job.status != JobStatus.PENDING:
41
+ raise JobStateError(f"Cannot run job with status '{job.status}'; expected 'pending'")
42
+
43
+ ctx = ctx or ExecutionContext()
44
+ workflow = job.workflow
45
+
46
+ self._emit(Event.JOB_START, job)
47
+ job.status = JobStatus.RUNNING
48
+ job.started_at = datetime.now()
49
+
50
+ # Set up job-level timeout
51
+ job_alarm_set = False
52
+ if timeout_seconds is not None:
53
+ job_alarm_set = self._set_alarm(timeout_seconds, "Job")
54
+
55
+ outputs: dict[str, BaseModel] = {}
56
+ job_config = job.job_configuration
57
+
58
+ try:
59
+ for task_name, task_cls in workflow.topological_order():
60
+ task = task_cls()
61
+ task.name = task_name # instance-level override for named instances
62
+ deps = workflow.get_dependencies(task_name)
63
+ config_fields = workflow.get_config_fields(task_name)
64
+ config_values = (
65
+ job_config.get_config_for_task(task_name)
66
+ if job_config and config_fields
67
+ else {}
68
+ )
69
+
70
+ # Assemble input based on dependency type
71
+ if deps is None:
72
+ if config_values:
73
+ # Root task with config: build input from config values
74
+ input_type = get_input_type(task_cls)
75
+ task_input = input_type.model_validate(config_values)
76
+ else:
77
+ task_input = job.config
78
+ elif isinstance(deps, str):
79
+ if config_values:
80
+ # Single dep with config: decompose upstream, merge with config
81
+ input_type = get_input_type(task_cls)
82
+ upstream_data = outputs[deps].model_dump()
83
+ down_fields = input_type.model_fields
84
+ merged: dict[str, object] = {
85
+ k: v for k, v in upstream_data.items() if k in down_fields
86
+ }
87
+ merged.update(config_values)
88
+ task_input = input_type.model_validate(merged)
89
+ else:
90
+ task_input = outputs[deps]
91
+ elif isinstance(deps, tuple):
92
+ upstream_name, field_name = deps
93
+ task_input = getattr(outputs[upstream_name], field_name)
94
+ elif isinstance(deps, dict):
95
+ input_type = get_input_type(task_cls)
96
+ field_values: dict[str, object] = {}
97
+ for fname, upstream_ref in deps.items():
98
+ if isinstance(upstream_ref, tuple):
99
+ up_name, up_field = upstream_ref
100
+ field_values[fname] = getattr(outputs[up_name], up_field)
101
+ else:
102
+ field_values[fname] = outputs[upstream_ref]
103
+ if config_values:
104
+ field_values.update(config_values)
105
+ task_input = input_type.model_validate(field_values)
106
+ else:
107
+ task_input = job.config # pragma: no cover
108
+
109
+ task_started = datetime.now()
110
+ self._emit(Event.TASK_START, job, task)
111
+
112
+ # Set up per-task timeout
113
+ task_alarm_set = False
114
+ if task.timeout_seconds is not None:
115
+ task_alarm_set = self._set_alarm(task.timeout_seconds, task.name)
116
+
117
+ try:
118
+ output = task.run(task_input, ctx)
119
+
120
+ # Validate output matches declared type
121
+ expected_output_type = get_output_type(task_cls)
122
+ if not isinstance(output, expected_output_type):
123
+ raise TaskOutputTypeError(
124
+ f"Task '{task.name}' returned {type(output).__name__}, "
125
+ f"expected {expected_output_type.__name__}"
126
+ )
127
+
128
+ duration = (datetime.now() - task_started).total_seconds()
129
+ outputs[task.name] = output
130
+ job.task_results.append(
131
+ TaskResult(
132
+ task_name=task.name,
133
+ status=TaskStatus.COMPLETED,
134
+ output=output,
135
+ started_at=task_started,
136
+ duration_seconds=duration,
137
+ )
138
+ )
139
+ self._emit(Event.TASK_COMPLETE, job, task, output)
140
+ except Exception as exc:
141
+ duration = (datetime.now() - task_started).total_seconds()
142
+ job.status = JobStatus.FAILED
143
+ job.error = str(exc)
144
+ job.failed_task = task.name
145
+ job.completed_at = datetime.now()
146
+ job.task_results.append(
147
+ TaskResult(
148
+ task_name=task.name,
149
+ status=TaskStatus.FAILED,
150
+ output=None,
151
+ started_at=task_started,
152
+ duration_seconds=duration,
153
+ error=str(exc),
154
+ )
155
+ )
156
+ self._emit(Event.TASK_FAIL, job, task, exc)
157
+ self._emit(Event.JOB_FAIL, job)
158
+ return job
159
+ finally:
160
+ if task_alarm_set:
161
+ signal.alarm(0)
162
+ finally:
163
+ if job_alarm_set:
164
+ signal.alarm(0)
165
+
166
+ job.status = JobStatus.COMPLETED
167
+ job.result = outputs[workflow.result_task_name]
168
+ job.completed_at = datetime.now()
169
+ self._emit(Event.JOB_COMPLETE, job)
170
+ return job
171
+
172
+ def _set_alarm(self, seconds: float, label: str) -> bool:
173
+ """Set a signal.alarm for timeout. Returns True if alarm was set."""
174
+ try:
175
+
176
+ def _handler(signum: int, frame: Any) -> None:
177
+ raise TaskTimeoutError(f"{label} timed out after {seconds}s")
178
+
179
+ signal.signal(signal.SIGALRM, _handler)
180
+ signal.alarm(int(seconds) if seconds >= 1 else 1)
181
+ return True
182
+ except (AttributeError, OSError):
183
+ warnings.warn(
184
+ f"signal.alarm not available on this platform; "
185
+ f"timeout for {label} will not be enforced",
186
+ stacklevel=2,
187
+ )
188
+ return False
189
+
190
+ def _emit(self, event: Event, *args: object) -> None:
191
+ """Dispatch event to all hooks, swallowing any hook errors."""
192
+ for hook in self.hooks:
193
+ handler = getattr(hook, f"on_{event}", None)
194
+ if handler is not None:
195
+ try:
196
+ handler(*args)
197
+ except Exception:
198
+ warnings.warn(
199
+ f"Hook {type(hook).__name__} raised during {event}",
200
+ stacklevel=2,
201
+ )