forgeoptimizer 1.0.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.
- forgecli/__init__.py +4 -0
- forgecli/build/__init__.py +135 -0
- forgecli/build/apply.py +218 -0
- forgecli/build/caveman_optimize.py +37 -0
- forgecli/build/diff_extract.py +218 -0
- forgecli/build/llm.py +167 -0
- forgecli/build/optimize.py +37 -0
- forgecli/build/pipeline.py +76 -0
- forgecli/build/retrieval.py +157 -0
- forgecli/build/summarize.py +76 -0
- forgecli/build/test_run.py +75 -0
- forgecli/builder/__init__.py +11 -0
- forgecli/builder/builder.py +53 -0
- forgecli/builder/editor.py +36 -0
- forgecli/builder/formatter.py +31 -0
- forgecli/cli/__init__.py +5 -0
- forgecli/cli/bootstrap.py +281 -0
- forgecli/cli/commands_graph.py +137 -0
- forgecli/cli/commands_wrappers.py +63 -0
- forgecli/cli/main.py +122 -0
- forgecli/cli/ui.py +56 -0
- forgecli/config/__init__.py +28 -0
- forgecli/config/loader.py +85 -0
- forgecli/config/settings.py +204 -0
- forgecli/config/writer.py +90 -0
- forgecli/core/__init__.py +35 -0
- forgecli/core/container.py +68 -0
- forgecli/core/context.py +54 -0
- forgecli/core/credentials.py +114 -0
- forgecli/core/errors.py +27 -0
- forgecli/core/events.py +67 -0
- forgecli/core/logging.py +47 -0
- forgecli/core/models.py +159 -0
- forgecli/core/plugins.py +56 -0
- forgecli/core/service.py +22 -0
- forgecli/docs/__init__.py +5 -0
- forgecli/docs/generator.py +119 -0
- forgecli/engine/__init__.py +105 -0
- forgecli/engine/context.py +171 -0
- forgecli/engine/defaults.py +89 -0
- forgecli/engine/events.py +185 -0
- forgecli/engine/execution.py +526 -0
- forgecli/engine/plugins.py +146 -0
- forgecli/engine/runner.py +155 -0
- forgecli/engine/stages/__init__.py +33 -0
- forgecli/engine/stages/caveman_optimizer.py +47 -0
- forgecli/engine/stages/context_optimizer.py +44 -0
- forgecli/engine/stages/execution_engine_stage.py +108 -0
- forgecli/engine/stages/git_engine.py +30 -0
- forgecli/engine/stages/intent_analyzer.py +38 -0
- forgecli/engine/stages/model_router.py +66 -0
- forgecli/engine/stages/planning_engine.py +45 -0
- forgecli/engine/stages/repository_analyzer.py +54 -0
- forgecli/engine/stages/validation_engine.py +89 -0
- forgecli/git/__init__.py +9 -0
- forgecli/git/repo.py +55 -0
- forgecli/git/service.py +45 -0
- forgecli/graph/__init__.py +56 -0
- forgecli/graph/backend_graphify.py +453 -0
- forgecli/graph/edge.py +19 -0
- forgecli/graph/graph.py +85 -0
- forgecli/graph/graphify.py +412 -0
- forgecli/graph/indexer.py +82 -0
- forgecli/graph/node.py +47 -0
- forgecli/graph/repository.py +164 -0
- forgecli/memory/__init__.py +12 -0
- forgecli/memory/cache.py +61 -0
- forgecli/memory/history.py +138 -0
- forgecli/memory/store.py +87 -0
- forgecli/optimizer/__init__.py +14 -0
- forgecli/optimizer/caveman/__init__.py +173 -0
- forgecli/optimizer/caveman/cli.py +64 -0
- forgecli/optimizer/caveman/decorator.py +67 -0
- forgecli/optimizer/caveman/factory.py +51 -0
- forgecli/optimizer/caveman/ruleset.py +156 -0
- forgecli/optimizer/caveman/state.py +52 -0
- forgecli/optimizer/chunker.py +85 -0
- forgecli/optimizer/optimizer.py +81 -0
- forgecli/optimizer/ponytail/__init__.py +183 -0
- forgecli/optimizer/ponytail/cli.py +168 -0
- forgecli/optimizer/ponytail/decorator.py +70 -0
- forgecli/optimizer/ponytail/factory.py +51 -0
- forgecli/optimizer/ponytail/ruleset.py +168 -0
- forgecli/optimizer/ponytail/state.py +53 -0
- forgecli/optimizer/ranker.py +37 -0
- forgecli/optimizer/summarizer.py +44 -0
- forgecli/orchestrator/__init__.py +707 -0
- forgecli/planner/__init__.py +56 -0
- forgecli/planner/agent.py +61 -0
- forgecli/planner/plan.py +65 -0
- forgecli/planner/planner.py +17 -0
- forgecli/planner/render.py +271 -0
- forgecli/planner/serialize.py +106 -0
- forgecli/planner/software.py +834 -0
- forgecli/platform/__init__.py +91 -0
- forgecli/platform/core.py +201 -0
- forgecli/platform/deps.py +354 -0
- forgecli/platform/paths.py +236 -0
- forgecli/platform/shell.py +176 -0
- forgecli/platform/update.py +252 -0
- forgecli/plugins/__init__.py +251 -0
- forgecli/prompts/__init__.py +11 -0
- forgecli/prompts/loader.py +28 -0
- forgecli/prompts/registry.py +32 -0
- forgecli/prompts/renderer.py +23 -0
- forgecli/providers/__init__.py +39 -0
- forgecli/providers/anthropic.py +206 -0
- forgecli/providers/base.py +206 -0
- forgecli/providers/builtin.py +26 -0
- forgecli/providers/conversation.py +78 -0
- forgecli/providers/google.py +295 -0
- forgecli/providers/http_base.py +211 -0
- forgecli/providers/mock.py +113 -0
- forgecli/providers/openai.py +202 -0
- forgecli/providers/openai_compatible.py +728 -0
- forgecli/providers/router.py +345 -0
- forgecli/providers/router_state.py +89 -0
- forgecli/review/__init__.py +45 -0
- forgecli/review/analyzer.py +124 -0
- forgecli/review/analyzers/__init__.py +1 -0
- forgecli/review/analyzers/architecture.py +258 -0
- forgecli/review/analyzers/complexity.py +167 -0
- forgecli/review/analyzers/dead_code.py +262 -0
- forgecli/review/analyzers/duplicates.py +163 -0
- forgecli/review/analyzers/performance.py +165 -0
- forgecli/review/analyzers/security.py +251 -0
- forgecli/review/finding.py +68 -0
- forgecli/review/report.py +311 -0
- forgecli/review/repository.py +131 -0
- forgecli/review/suggestions.py +98 -0
- forgecli/runtime/__init__.py +6 -0
- forgecli/runtime/cache_store.py +77 -0
- forgecli/runtime/prepare.py +199 -0
- forgecli/runtime/wrappers.py +152 -0
- forgecli/sdk/__init__.py +132 -0
- forgecli/sdk/events.py +206 -0
- forgecli/sdk/interfaces.py +282 -0
- forgecli/sdk/loader.py +243 -0
- forgecli/sdk/manager.py +693 -0
- forgecli/sdk/manifest.py +397 -0
- forgecli/sdk/sandbox.py +197 -0
- forgecli/sdk/version.py +316 -0
- forgecli/templates/__init__.py +9 -0
- forgecli/templates/engine.py +37 -0
- forgecli/templates/registry.py +32 -0
- forgecli/utils/__init__.py +19 -0
- forgecli/utils/fs.py +91 -0
- forgecli/utils/ids.py +11 -0
- forgecli/utils/io.py +27 -0
- forgecli/utils/paths.py +70 -0
- forgecli/utils/timing.py +25 -0
- forgeoptimizer-1.0.0.dist-info/METADATA +169 -0
- forgeoptimizer-1.0.0.dist-info/RECORD +156 -0
- forgeoptimizer-1.0.0.dist-info/WHEEL +4 -0
- forgeoptimizer-1.0.0.dist-info/entry_points.txt +2 -0
- forgeoptimizer-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Typed context that flows through the engine's eight stages.
|
|
2
|
+
|
|
3
|
+
Each stage reads from the context, mutates one or two fields, and
|
|
4
|
+
returns a :class:`~forgecli.engine.execution.StageResult`. The
|
|
5
|
+
context is the *only* mechanism stages use to share data — there
|
|
6
|
+
are no hidden globals.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from forgecli.plugins import Intent
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Per-stage payloads
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class IntentAnalysis:
|
|
27
|
+
"""The output of the Intent Analyzer stage."""
|
|
28
|
+
|
|
29
|
+
intent: Intent
|
|
30
|
+
confidence: float
|
|
31
|
+
rationale: tuple[str, ...] = ()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class RetrievalResult:
|
|
36
|
+
"""The output of the Repository Analyzer stage."""
|
|
37
|
+
|
|
38
|
+
query: str
|
|
39
|
+
matched_nodes: tuple[Mapping[str, Any], ...] = ()
|
|
40
|
+
context_text: str = ""
|
|
41
|
+
artifacts: dict[str, Path] = field(default_factory=dict)
|
|
42
|
+
notes: tuple[str, ...] = ()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class ModelSelection:
|
|
47
|
+
"""The output of the Model Router stage."""
|
|
48
|
+
|
|
49
|
+
provider: str
|
|
50
|
+
model: str
|
|
51
|
+
mode: str = "explicit" # "explicit" | "cheapest" | "fallback"
|
|
52
|
+
cost_in: float = 0.0
|
|
53
|
+
cost_out: float = 0.0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class StageLog:
|
|
58
|
+
"""A single record of how a stage performed.
|
|
59
|
+
|
|
60
|
+
The engine accumulates these on :class:`EngineContext` so
|
|
61
|
+
callers can render a final report.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
stage: str
|
|
65
|
+
status: str
|
|
66
|
+
started_at: float
|
|
67
|
+
finished_at: float | None = None
|
|
68
|
+
notes: tuple[str, ...] = ()
|
|
69
|
+
error: str | None = None
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def duration_seconds(self) -> float:
|
|
73
|
+
if self.finished_at is None:
|
|
74
|
+
return 0.0
|
|
75
|
+
return max(0.0, self.finished_at - self.started_at)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Engine context
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class EngineContext:
|
|
85
|
+
"""Mutable state shared by every stage of the engine.
|
|
86
|
+
|
|
87
|
+
The context is constructed by :class:`EngineBuilder` and
|
|
88
|
+
passed to each :class:`Stage`. Stages may set the
|
|
89
|
+
``intent_analysis`` / ``retrieval`` / ``model_selection`` /
|
|
90
|
+
``plan`` / ``response`` / ``diff_text`` / ``applied_files`` /
|
|
91
|
+
``test_*`` fields; downstream stages read them.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
prompt: str
|
|
95
|
+
cwd: Path
|
|
96
|
+
run_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
|
|
97
|
+
started_at: float = field(default_factory=time.time)
|
|
98
|
+
|
|
99
|
+
# Stage 1: Intent
|
|
100
|
+
intent_analysis: IntentAnalysis | None = None
|
|
101
|
+
# Stage 2: Retrieval
|
|
102
|
+
retrieval: RetrievalResult | None = None
|
|
103
|
+
# Stage 3: Caveman optimization (runs before context optimization)
|
|
104
|
+
caveman_optimized_request: Any = None
|
|
105
|
+
caveman_optimized_notes: tuple[str, ...] = ()
|
|
106
|
+
# Stage 4: Context optimization
|
|
107
|
+
optimized_request: Any = None
|
|
108
|
+
optimized_notes: tuple[str, ...] = ()
|
|
109
|
+
# Stage 5: Plan
|
|
110
|
+
plan: Any = None # SoftwarePlan; kept loose to avoid an import cycle
|
|
111
|
+
# Stage 5: Model
|
|
112
|
+
model_selection: ModelSelection | None = None
|
|
113
|
+
# Stage 6: Execution
|
|
114
|
+
response: Any = None
|
|
115
|
+
# Stage 7: Validation
|
|
116
|
+
diff_text: str = ""
|
|
117
|
+
applied_files: list[Path] = field(default_factory=list)
|
|
118
|
+
test_stdout: str = ""
|
|
119
|
+
test_stderr: str = ""
|
|
120
|
+
test_returncode: int | None = None
|
|
121
|
+
fix_attempts: int = 0
|
|
122
|
+
# Stage 8: Git
|
|
123
|
+
staged: bool = False
|
|
124
|
+
pushed: bool = False
|
|
125
|
+
|
|
126
|
+
# Free-form extras for plugins / advanced stages.
|
|
127
|
+
extras: dict[str, Any] = field(default_factory=dict)
|
|
128
|
+
|
|
129
|
+
# Per-stage log (append-only).
|
|
130
|
+
log: list[StageLog] = field(default_factory=list)
|
|
131
|
+
|
|
132
|
+
def to_log_dict(self) -> dict[str, Any]:
|
|
133
|
+
"""Return a JSON-serializable snapshot of the context (no Paths)."""
|
|
134
|
+
return {
|
|
135
|
+
"run_id": self.run_id,
|
|
136
|
+
"started_at": self.started_at,
|
|
137
|
+
"intent": self.intent_analysis.intent.value
|
|
138
|
+
if self.intent_analysis
|
|
139
|
+
else None,
|
|
140
|
+
"intent_confidence": self.intent_analysis.confidence
|
|
141
|
+
if self.intent_analysis
|
|
142
|
+
else 0.0,
|
|
143
|
+
"retrieval_query": self.retrieval.query if self.retrieval else None,
|
|
144
|
+
"retrieval_match_count": len(self.retrieval.matched_nodes)
|
|
145
|
+
if self.retrieval
|
|
146
|
+
else 0,
|
|
147
|
+
"caveman_optimized_notes": list(self.caveman_optimized_notes),
|
|
148
|
+
"optimized_notes": list(self.optimized_notes),
|
|
149
|
+
"has_plan": self.plan is not None,
|
|
150
|
+
"model": self.model_selection.model if self.model_selection else None,
|
|
151
|
+
"provider": self.model_selection.provider
|
|
152
|
+
if self.model_selection
|
|
153
|
+
else None,
|
|
154
|
+
"response_present": self.response is not None,
|
|
155
|
+
"diff_length": len(self.diff_text),
|
|
156
|
+
"applied_files": [str(p) for p in self.applied_files],
|
|
157
|
+
"test_returncode": self.test_returncode,
|
|
158
|
+
"fix_attempts": self.fix_attempts,
|
|
159
|
+
"staged": self.staged,
|
|
160
|
+
"pushed": self.pushed,
|
|
161
|
+
"stage_count": len(self.log),
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
__all__ = [
|
|
166
|
+
"EngineContext",
|
|
167
|
+
"IntentAnalysis",
|
|
168
|
+
"ModelSelection",
|
|
169
|
+
"RetrievalResult",
|
|
170
|
+
"StageLog",
|
|
171
|
+
]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Default stage wiring for the Execution Engine.
|
|
2
|
+
|
|
3
|
+
Provides :func:`default_registry` which populates a
|
|
4
|
+
:class:`~forgecli.engine.execution.StageRegistry` with all eight
|
|
5
|
+
DEFAULT_PIPELINE stages. Callers can then build an engine with::
|
|
6
|
+
|
|
7
|
+
registry = default_registry()
|
|
8
|
+
engine = ExecutionEngine.from_registry(registry)
|
|
9
|
+
result = await engine.run(context)
|
|
10
|
+
|
|
11
|
+
Individual stages can be overridden via
|
|
12
|
+
:meth:`StageRegistry.replace` before building the engine.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from forgecli.engine.execution import Stage, StageRegistry
|
|
20
|
+
from forgecli.engine.stages import (
|
|
21
|
+
CavemanOptimizerStage,
|
|
22
|
+
ContextOptimizerStage,
|
|
23
|
+
ExecutionEngineStage,
|
|
24
|
+
GitEngineStage,
|
|
25
|
+
IntentAnalyzerStage,
|
|
26
|
+
ModelRouterStage,
|
|
27
|
+
PlanningEngineStage,
|
|
28
|
+
RepositoryAnalyzerStage,
|
|
29
|
+
ValidationEngineStage,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def default_registry(
|
|
34
|
+
*,
|
|
35
|
+
provider: Any = None,
|
|
36
|
+
optimizer: Any = None,
|
|
37
|
+
caveman_optimizer: Any = None,
|
|
38
|
+
graph: Any = None,
|
|
39
|
+
classifier: Any = None,
|
|
40
|
+
router: Any = None,
|
|
41
|
+
test_command: str | None = None,
|
|
42
|
+
plugin_registry: Any = None,
|
|
43
|
+
**stage_kwargs: Any,
|
|
44
|
+
) -> StageRegistry:
|
|
45
|
+
"""Create a :class:`StageRegistry` with all nine default pipeline stages.
|
|
46
|
+
|
|
47
|
+
Keyword arguments are forwarded to stage constructors:
|
|
48
|
+
|
|
49
|
+
* ``provider`` → :class:`ExecutionEngineStage`
|
|
50
|
+
* ``optimizer`` → :class:`ContextOptimizerStage`
|
|
51
|
+
* ``caveman_optimizer`` → :class:`CavemanOptimizerStage`
|
|
52
|
+
* ``graph`` → :class:`RepositoryAnalyzerStage`
|
|
53
|
+
* ``classifier`` → :class:`IntentAnalyzerStage`
|
|
54
|
+
* ``router`` → :class:`ModelRouterStage`
|
|
55
|
+
* ``test_command`` → :class:`ValidationEngineStage`
|
|
56
|
+
* ``plugin_registry`` → a :class:`~forgecli.plugins.PluginRegistry` whose
|
|
57
|
+
stages are bulk-loaded into the registry after default stages.
|
|
58
|
+
The plugin registry is also linked so future
|
|
59
|
+
:meth:`~forgecli.plugins.PluginRegistry.register_stage` /
|
|
60
|
+
:meth:`~forgecli.plugins.PluginRegistry.replace_stage` calls
|
|
61
|
+
are mirrored into this registry.
|
|
62
|
+
|
|
63
|
+
Remaining kwargs are passed to stages that accept them by name
|
|
64
|
+
(e.g. ``auto_commit`` for :class:`GitEngineStage`).
|
|
65
|
+
"""
|
|
66
|
+
registry = StageRegistry()
|
|
67
|
+
|
|
68
|
+
stages: list[Stage] = [
|
|
69
|
+
IntentAnalyzerStage(classifier=classifier),
|
|
70
|
+
RepositoryAnalyzerStage(graph=graph),
|
|
71
|
+
CavemanOptimizerStage(optimizer=caveman_optimizer),
|
|
72
|
+
ContextOptimizerStage(optimizer=optimizer),
|
|
73
|
+
PlanningEngineStage(**{k: v for k, v in stage_kwargs.items() if k in ("enabled",)}),
|
|
74
|
+
ModelRouterStage(router=router),
|
|
75
|
+
ExecutionEngineStage(provider=provider),
|
|
76
|
+
ValidationEngineStage(test_command=test_command),
|
|
77
|
+
GitEngineStage(),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
for stage in stages:
|
|
81
|
+
registry.register(stage)
|
|
82
|
+
|
|
83
|
+
if plugin_registry is not None:
|
|
84
|
+
plugin_registry.link_engine_registry(registry)
|
|
85
|
+
|
|
86
|
+
return registry
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
__all__ = ["default_registry"]
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Structured event bus for the Execution Engine.
|
|
2
|
+
|
|
3
|
+
The :class:`EventBus` is a tiny pub/sub dispatcher for the four
|
|
4
|
+
event kinds the engine emits:
|
|
5
|
+
|
|
6
|
+
* :class:`StageEvent` — a stage transitioned to a new state.
|
|
7
|
+
* :class:`ProgressEvent` — fractional progress within a stage.
|
|
8
|
+
* :class:`TextLogEvent` — a free-form log line (the engine's
|
|
9
|
+
"streaming log" surface).
|
|
10
|
+
* :class:`EngineEvent` — the abstract base; every concrete event
|
|
11
|
+
inherits from it.
|
|
12
|
+
|
|
13
|
+
Subscribers can be sync or async; the engine handles fan-out
|
|
14
|
+
sequentially. A single :class:`asyncio.Event` (the cancellation
|
|
15
|
+
token) is checked between stages and during long operations so a
|
|
16
|
+
caller can abort cleanly.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import contextlib
|
|
23
|
+
from collections.abc import Awaitable, Callable
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from datetime import UTC, datetime
|
|
26
|
+
from enum import Enum
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LogLevel(str, Enum):
|
|
30
|
+
"""Severity for :class:`TextLogEvent`."""
|
|
31
|
+
|
|
32
|
+
DEBUG = "debug"
|
|
33
|
+
INFO = "info"
|
|
34
|
+
WARN = "warn"
|
|
35
|
+
ERROR = "error"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class EngineEvent:
|
|
40
|
+
"""Abstract base of every event the engine emits."""
|
|
41
|
+
|
|
42
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
43
|
+
run_id: str = ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class StageEvent(EngineEvent):
|
|
48
|
+
"""A stage transitioned to a new state."""
|
|
49
|
+
|
|
50
|
+
stage: str = ""
|
|
51
|
+
status: str = "running" # "running" | "succeeded" | "failed" | "skipped" | "retrying"
|
|
52
|
+
attempt: int = 1
|
|
53
|
+
note: str | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class ProgressEvent(EngineEvent):
|
|
58
|
+
"""Fractional progress within a stage (0.0 - 1.0)."""
|
|
59
|
+
|
|
60
|
+
stage: str = ""
|
|
61
|
+
progress: float = 0.0
|
|
62
|
+
message: str | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class TextLogEvent(EngineEvent):
|
|
67
|
+
"""A free-form log line emitted by a stage."""
|
|
68
|
+
|
|
69
|
+
level: LogLevel = LogLevel.INFO
|
|
70
|
+
source: str = ""
|
|
71
|
+
message: str = ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
EventHandler = Callable[[EngineEvent], "None | Awaitable[None]"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class EventBus:
|
|
78
|
+
"""In-process pub/sub bus for engine events.
|
|
79
|
+
|
|
80
|
+
Subscribers are registered per event class. Handlers may be sync
|
|
81
|
+
or async; the engine awaits any coroutines it gets back. A single
|
|
82
|
+
:class:`asyncio.Event` (the cancellation token) is checked between
|
|
83
|
+
stages so the caller can abort cleanly.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self) -> None:
|
|
87
|
+
self._subscribers: dict[type, list[EventHandler]] = {}
|
|
88
|
+
self.cancellation = asyncio.Event()
|
|
89
|
+
self.history: list[EngineEvent] = []
|
|
90
|
+
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
# Subscription
|
|
93
|
+
# ------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def subscribe(self, event_cls: type[EngineEvent], handler: EventHandler) -> None:
|
|
96
|
+
self._subscribers.setdefault(event_cls, []).append(handler)
|
|
97
|
+
|
|
98
|
+
def unsubscribe(self, event_cls: type[EngineEvent], handler: EventHandler) -> None:
|
|
99
|
+
bucket = self._subscribers.get(event_cls)
|
|
100
|
+
if not bucket:
|
|
101
|
+
return
|
|
102
|
+
with contextlib.suppress(ValueError):
|
|
103
|
+
bucket.remove(handler)
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
# Cancellation
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def cancel(self) -> None:
|
|
110
|
+
"""Request cancellation. The engine checks ``cancellation`` between stages."""
|
|
111
|
+
self.cancellation.set()
|
|
112
|
+
|
|
113
|
+
def reset_cancellation(self) -> None:
|
|
114
|
+
self.cancellation.clear()
|
|
115
|
+
|
|
116
|
+
def is_cancelled(self) -> bool:
|
|
117
|
+
return self.cancellation.is_set()
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
# Publishing
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def publish(self, event: EngineEvent) -> None:
|
|
124
|
+
"""Publish synchronously; async handlers are scheduled."""
|
|
125
|
+
self.history.append(event)
|
|
126
|
+
for handler in list(self._subscribers.get(type(event), ())):
|
|
127
|
+
try:
|
|
128
|
+
result = handler(event)
|
|
129
|
+
except Exception:
|
|
130
|
+
continue
|
|
131
|
+
if asyncio.iscoroutine(result):
|
|
132
|
+
# Best-effort: schedule on the running loop if any.
|
|
133
|
+
try:
|
|
134
|
+
loop = asyncio.get_running_loop()
|
|
135
|
+
task = loop.create_task(result)
|
|
136
|
+
task.add_done_callback(self._discard_task)
|
|
137
|
+
except RuntimeError:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _discard_task(task: asyncio.Task) -> None:
|
|
142
|
+
# Placeholder to keep a strong reference to the task until it
|
|
143
|
+
# completes. Without this, asyncio may garbage-collect the
|
|
144
|
+
# task mid-flight and emit "Task was destroyed but it is
|
|
145
|
+
# pending" warnings.
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
async def publish_and_drain(self, event: EngineEvent) -> None:
|
|
149
|
+
"""Publish and await any async handler results."""
|
|
150
|
+
self.history.append(event)
|
|
151
|
+
for handler in list(self._subscribers.get(type(event), ())):
|
|
152
|
+
try:
|
|
153
|
+
result = handler(event)
|
|
154
|
+
except Exception:
|
|
155
|
+
continue
|
|
156
|
+
if asyncio.iscoroutine(result):
|
|
157
|
+
await result
|
|
158
|
+
|
|
159
|
+
# ------------------------------------------------------------------
|
|
160
|
+
# Helpers
|
|
161
|
+
# ------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
def drain(self) -> list[EngineEvent]:
|
|
164
|
+
return list(self.history)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# Cancellation sentinel exception
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class EngineCancelledError(Exception):
|
|
173
|
+
"""Raised by the engine when the cancellation token is set."""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
__all__ = [
|
|
177
|
+
"EngineCancelledError",
|
|
178
|
+
"EngineEvent",
|
|
179
|
+
"EventBus",
|
|
180
|
+
"EventHandler",
|
|
181
|
+
"LogLevel",
|
|
182
|
+
"ProgressEvent",
|
|
183
|
+
"StageEvent",
|
|
184
|
+
"TextLogEvent",
|
|
185
|
+
]
|