yee88 0.3.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.
- yee88/__init__.py +1 -0
- yee88/api.py +116 -0
- yee88/backends.py +25 -0
- yee88/backends_helpers.py +14 -0
- yee88/cli/__init__.py +228 -0
- yee88/cli/config.py +320 -0
- yee88/cli/doctor.py +173 -0
- yee88/cli/init.py +113 -0
- yee88/cli/onboarding_cmd.py +126 -0
- yee88/cli/plugins.py +196 -0
- yee88/cli/run.py +419 -0
- yee88/cli/topic.py +355 -0
- yee88/commands.py +134 -0
- yee88/config.py +142 -0
- yee88/config_migrations.py +124 -0
- yee88/config_watch.py +146 -0
- yee88/context.py +9 -0
- yee88/directives.py +146 -0
- yee88/engines.py +53 -0
- yee88/events.py +170 -0
- yee88/ids.py +17 -0
- yee88/lockfile.py +158 -0
- yee88/logging.py +283 -0
- yee88/markdown.py +298 -0
- yee88/model.py +77 -0
- yee88/plugins.py +312 -0
- yee88/presenter.py +25 -0
- yee88/progress.py +99 -0
- yee88/router.py +113 -0
- yee88/runner.py +712 -0
- yee88/runner_bridge.py +619 -0
- yee88/runners/__init__.py +1 -0
- yee88/runners/claude.py +483 -0
- yee88/runners/codex.py +656 -0
- yee88/runners/mock.py +221 -0
- yee88/runners/opencode.py +505 -0
- yee88/runners/pi.py +523 -0
- yee88/runners/run_options.py +39 -0
- yee88/runners/tool_actions.py +90 -0
- yee88/runtime_loader.py +207 -0
- yee88/scheduler.py +159 -0
- yee88/schemas/__init__.py +1 -0
- yee88/schemas/claude.py +238 -0
- yee88/schemas/codex.py +169 -0
- yee88/schemas/opencode.py +51 -0
- yee88/schemas/pi.py +117 -0
- yee88/settings.py +360 -0
- yee88/telegram/__init__.py +20 -0
- yee88/telegram/api_models.py +37 -0
- yee88/telegram/api_schemas.py +152 -0
- yee88/telegram/backend.py +163 -0
- yee88/telegram/bridge.py +425 -0
- yee88/telegram/chat_prefs.py +242 -0
- yee88/telegram/chat_sessions.py +112 -0
- yee88/telegram/client.py +409 -0
- yee88/telegram/client_api.py +539 -0
- yee88/telegram/commands/__init__.py +12 -0
- yee88/telegram/commands/agent.py +196 -0
- yee88/telegram/commands/cancel.py +116 -0
- yee88/telegram/commands/dispatch.py +111 -0
- yee88/telegram/commands/executor.py +449 -0
- yee88/telegram/commands/file_transfer.py +586 -0
- yee88/telegram/commands/handlers.py +45 -0
- yee88/telegram/commands/media.py +143 -0
- yee88/telegram/commands/menu.py +139 -0
- yee88/telegram/commands/model.py +215 -0
- yee88/telegram/commands/overrides.py +159 -0
- yee88/telegram/commands/parse.py +30 -0
- yee88/telegram/commands/plan.py +16 -0
- yee88/telegram/commands/reasoning.py +234 -0
- yee88/telegram/commands/reply.py +23 -0
- yee88/telegram/commands/topics.py +332 -0
- yee88/telegram/commands/trigger.py +143 -0
- yee88/telegram/context.py +140 -0
- yee88/telegram/engine_defaults.py +86 -0
- yee88/telegram/engine_overrides.py +105 -0
- yee88/telegram/files.py +178 -0
- yee88/telegram/loop.py +1822 -0
- yee88/telegram/onboarding.py +1088 -0
- yee88/telegram/outbox.py +177 -0
- yee88/telegram/parsing.py +239 -0
- yee88/telegram/render.py +198 -0
- yee88/telegram/state_store.py +88 -0
- yee88/telegram/topic_state.py +334 -0
- yee88/telegram/topics.py +256 -0
- yee88/telegram/trigger_mode.py +68 -0
- yee88/telegram/types.py +63 -0
- yee88/telegram/voice.py +110 -0
- yee88/transport.py +53 -0
- yee88/transport_runtime.py +323 -0
- yee88/transports.py +76 -0
- yee88/utils/__init__.py +1 -0
- yee88/utils/git.py +87 -0
- yee88/utils/json_state.py +21 -0
- yee88/utils/paths.py +47 -0
- yee88/utils/streams.py +44 -0
- yee88/utils/subprocess.py +86 -0
- yee88/worktrees.py +135 -0
- yee88-0.3.0.dist-info/METADATA +116 -0
- yee88-0.3.0.dist-info/RECORD +103 -0
- yee88-0.3.0.dist-info/WHEEL +4 -0
- yee88-0.3.0.dist-info/entry_points.txt +11 -0
- yee88-0.3.0.dist-info/licenses/LICENSE +21 -0
yee88/markdown.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import textwrap
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .model import Action, ActionEvent, StartedEvent, TakopiEvent
|
|
9
|
+
from .progress import ProgressState
|
|
10
|
+
from .transport import RenderedMessage
|
|
11
|
+
from .utils.paths import relativize_path
|
|
12
|
+
|
|
13
|
+
STATUS = {"running": "▸", "update": "↻", "done": "✓", "fail": "✗"}
|
|
14
|
+
HEADER_SEP = " · "
|
|
15
|
+
HARD_BREAK = " \n"
|
|
16
|
+
|
|
17
|
+
MAX_PROGRESS_CMD_LEN = 300
|
|
18
|
+
MAX_FILE_CHANGES_INLINE = 3
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class MarkdownParts:
|
|
23
|
+
header: str
|
|
24
|
+
body: str | None = None
|
|
25
|
+
footer: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def assemble_markdown_parts(parts: MarkdownParts) -> str:
|
|
29
|
+
return "\n\n".join(
|
|
30
|
+
chunk for chunk in (parts.header, parts.body, parts.footer) if chunk
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def format_changed_file_path(path: str, *, base_dir: Path | None = None) -> str:
|
|
35
|
+
return f"`{relativize_path(path, base_dir=base_dir)}`"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def format_elapsed(elapsed_s: float) -> str:
|
|
39
|
+
total = max(0, int(elapsed_s))
|
|
40
|
+
minutes, seconds = divmod(total, 60)
|
|
41
|
+
hours, minutes = divmod(minutes, 60)
|
|
42
|
+
if hours:
|
|
43
|
+
return f"{hours}h {minutes:02d}m"
|
|
44
|
+
if minutes:
|
|
45
|
+
return f"{minutes}m {seconds:02d}s"
|
|
46
|
+
return f"{seconds}s"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def format_header(
|
|
50
|
+
elapsed_s: float, item: int | None, *, label: str, engine: str
|
|
51
|
+
) -> str:
|
|
52
|
+
elapsed = format_elapsed(elapsed_s)
|
|
53
|
+
parts = [label, engine]
|
|
54
|
+
parts.append(elapsed)
|
|
55
|
+
if item is not None:
|
|
56
|
+
parts.append(f"step {item}")
|
|
57
|
+
return HEADER_SEP.join(parts)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def shorten(text: str, width: int | None) -> str:
|
|
61
|
+
if width is None:
|
|
62
|
+
return text
|
|
63
|
+
if width <= 0:
|
|
64
|
+
return ""
|
|
65
|
+
if len(text) <= width:
|
|
66
|
+
return text
|
|
67
|
+
return textwrap.shorten(text, width=width, placeholder="…")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def action_status(action: Action, *, completed: bool, ok: bool | None = None) -> str:
|
|
71
|
+
if not completed:
|
|
72
|
+
return STATUS["running"]
|
|
73
|
+
if ok is not None:
|
|
74
|
+
return STATUS["done"] if ok else STATUS["fail"]
|
|
75
|
+
detail = action.detail or {}
|
|
76
|
+
exit_code = detail.get("exit_code")
|
|
77
|
+
if isinstance(exit_code, int) and exit_code != 0:
|
|
78
|
+
return STATUS["fail"]
|
|
79
|
+
return STATUS["done"]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def action_suffix(action: Action) -> str:
|
|
83
|
+
detail = action.detail or {}
|
|
84
|
+
exit_code = detail.get("exit_code")
|
|
85
|
+
if isinstance(exit_code, int) and exit_code != 0:
|
|
86
|
+
return f" (exit {exit_code})"
|
|
87
|
+
return ""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def format_file_change_title(action: Action, *, command_width: int | None) -> str:
|
|
91
|
+
title = str(action.title or "")
|
|
92
|
+
detail = action.detail or {}
|
|
93
|
+
|
|
94
|
+
changes = detail.get("changes")
|
|
95
|
+
if isinstance(changes, list) and changes:
|
|
96
|
+
rendered: list[str] = []
|
|
97
|
+
for raw in changes:
|
|
98
|
+
path: str | None
|
|
99
|
+
kind: str | None
|
|
100
|
+
if isinstance(raw, dict):
|
|
101
|
+
path = raw.get("path")
|
|
102
|
+
kind = raw.get("kind")
|
|
103
|
+
else:
|
|
104
|
+
path = getattr(raw, "path", None)
|
|
105
|
+
kind = getattr(raw, "kind", None)
|
|
106
|
+
if not isinstance(path, str) or not path:
|
|
107
|
+
continue
|
|
108
|
+
verb = kind if isinstance(kind, str) and kind else "update"
|
|
109
|
+
rendered.append(f"{verb} {format_changed_file_path(path)}")
|
|
110
|
+
|
|
111
|
+
if rendered:
|
|
112
|
+
if len(rendered) > MAX_FILE_CHANGES_INLINE:
|
|
113
|
+
remaining = len(rendered) - MAX_FILE_CHANGES_INLINE
|
|
114
|
+
rendered = rendered[:MAX_FILE_CHANGES_INLINE] + [f"…({remaining} more)"]
|
|
115
|
+
inline = shorten(", ".join(rendered), command_width)
|
|
116
|
+
return f"files: {inline}"
|
|
117
|
+
|
|
118
|
+
fallback = title
|
|
119
|
+
relativized = relativize_path(fallback)
|
|
120
|
+
was_relativized = relativized != fallback
|
|
121
|
+
if was_relativized:
|
|
122
|
+
fallback = relativized
|
|
123
|
+
if (
|
|
124
|
+
fallback
|
|
125
|
+
and not (fallback.startswith("`") and fallback.endswith("`"))
|
|
126
|
+
and (was_relativized or os.sep in fallback or "/" in fallback)
|
|
127
|
+
):
|
|
128
|
+
fallback = f"`{fallback}`"
|
|
129
|
+
return f"files: {shorten(fallback, command_width)}"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def format_action_title(action: Action, *, command_width: int | None) -> str:
|
|
133
|
+
title = str(action.title or "")
|
|
134
|
+
kind = action.kind
|
|
135
|
+
if kind == "command":
|
|
136
|
+
title = shorten(title, command_width)
|
|
137
|
+
return f"`{title}`"
|
|
138
|
+
if kind == "tool":
|
|
139
|
+
title = shorten(title, command_width)
|
|
140
|
+
return f"tool: {title}"
|
|
141
|
+
if kind == "web_search":
|
|
142
|
+
title = shorten(title, command_width)
|
|
143
|
+
return f"searched: {title}"
|
|
144
|
+
if kind == "subagent":
|
|
145
|
+
title = shorten(title, command_width)
|
|
146
|
+
return f"subagent: {title}"
|
|
147
|
+
if kind == "file_change":
|
|
148
|
+
return format_file_change_title(action, command_width=command_width)
|
|
149
|
+
if kind in {"note", "warning"}:
|
|
150
|
+
return shorten(title, command_width)
|
|
151
|
+
return shorten(title, command_width)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def format_action_line(
|
|
155
|
+
action: Action,
|
|
156
|
+
phase: str,
|
|
157
|
+
ok: bool | None,
|
|
158
|
+
*,
|
|
159
|
+
command_width: int | None,
|
|
160
|
+
) -> str:
|
|
161
|
+
if phase != "completed":
|
|
162
|
+
status = STATUS["update"] if phase == "updated" else STATUS["running"]
|
|
163
|
+
return f"{status} {format_action_title(action, command_width=command_width)}"
|
|
164
|
+
status = action_status(action, completed=True, ok=ok)
|
|
165
|
+
suffix = action_suffix(action)
|
|
166
|
+
return (
|
|
167
|
+
f"{status} {format_action_title(action, command_width=command_width)}{suffix}"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def render_event_cli(event: TakopiEvent) -> list[str]:
|
|
172
|
+
match event:
|
|
173
|
+
case StartedEvent(engine=engine):
|
|
174
|
+
return [str(engine)]
|
|
175
|
+
case ActionEvent() as action_event:
|
|
176
|
+
action = action_event.action
|
|
177
|
+
if action.kind == "turn":
|
|
178
|
+
return []
|
|
179
|
+
return [
|
|
180
|
+
format_action_line(
|
|
181
|
+
action_event.action,
|
|
182
|
+
action_event.phase,
|
|
183
|
+
action_event.ok,
|
|
184
|
+
command_width=MAX_PROGRESS_CMD_LEN,
|
|
185
|
+
)
|
|
186
|
+
]
|
|
187
|
+
case _:
|
|
188
|
+
return []
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class MarkdownFormatter:
|
|
192
|
+
def __init__(
|
|
193
|
+
self,
|
|
194
|
+
*,
|
|
195
|
+
max_actions: int = 5,
|
|
196
|
+
command_width: int | None = MAX_PROGRESS_CMD_LEN,
|
|
197
|
+
) -> None:
|
|
198
|
+
self.max_actions = max(0, int(max_actions))
|
|
199
|
+
self.command_width = command_width
|
|
200
|
+
|
|
201
|
+
def render_progress_parts(
|
|
202
|
+
self,
|
|
203
|
+
state: ProgressState,
|
|
204
|
+
*,
|
|
205
|
+
elapsed_s: float,
|
|
206
|
+
label: str = "working",
|
|
207
|
+
) -> MarkdownParts:
|
|
208
|
+
step = state.action_count or None
|
|
209
|
+
header = format_header(
|
|
210
|
+
elapsed_s,
|
|
211
|
+
step,
|
|
212
|
+
label=label,
|
|
213
|
+
engine=state.engine,
|
|
214
|
+
)
|
|
215
|
+
body = self._assemble_body(self._format_actions(state))
|
|
216
|
+
return MarkdownParts(
|
|
217
|
+
header=header, body=body, footer=self._format_footer(state)
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def render_final_parts(
|
|
221
|
+
self,
|
|
222
|
+
state: ProgressState,
|
|
223
|
+
*,
|
|
224
|
+
elapsed_s: float,
|
|
225
|
+
status: str,
|
|
226
|
+
answer: str,
|
|
227
|
+
) -> MarkdownParts:
|
|
228
|
+
step = state.action_count or None
|
|
229
|
+
header = format_header(
|
|
230
|
+
elapsed_s,
|
|
231
|
+
step,
|
|
232
|
+
label=status,
|
|
233
|
+
engine=state.engine,
|
|
234
|
+
)
|
|
235
|
+
answer = (answer or "").strip()
|
|
236
|
+
body = answer if answer else None
|
|
237
|
+
return MarkdownParts(
|
|
238
|
+
header=header, body=body, footer=self._format_footer(state)
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def _format_footer(self, state: ProgressState) -> str | None:
|
|
242
|
+
lines: list[str] = []
|
|
243
|
+
if state.context_line:
|
|
244
|
+
lines.append(state.context_line)
|
|
245
|
+
if state.resume_line:
|
|
246
|
+
lines.append(state.resume_line)
|
|
247
|
+
if not lines:
|
|
248
|
+
return None
|
|
249
|
+
return HARD_BREAK.join(lines)
|
|
250
|
+
|
|
251
|
+
def _format_actions(self, state: ProgressState) -> list[str]:
|
|
252
|
+
actions = list(state.actions)
|
|
253
|
+
actions = [] if self.max_actions == 0 else actions[-self.max_actions :]
|
|
254
|
+
return [
|
|
255
|
+
format_action_line(
|
|
256
|
+
action_state.action,
|
|
257
|
+
action_state.display_phase,
|
|
258
|
+
action_state.ok,
|
|
259
|
+
command_width=self.command_width,
|
|
260
|
+
)
|
|
261
|
+
for action_state in actions
|
|
262
|
+
]
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
def _assemble_body(lines: list[str]) -> str | None:
|
|
266
|
+
if not lines:
|
|
267
|
+
return None
|
|
268
|
+
return HARD_BREAK.join(lines)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class MarkdownPresenter:
|
|
272
|
+
def __init__(self, *, formatter: MarkdownFormatter | None = None) -> None:
|
|
273
|
+
self._formatter = formatter or MarkdownFormatter()
|
|
274
|
+
|
|
275
|
+
def render_progress(
|
|
276
|
+
self,
|
|
277
|
+
state: ProgressState,
|
|
278
|
+
*,
|
|
279
|
+
elapsed_s: float,
|
|
280
|
+
label: str = "working",
|
|
281
|
+
) -> RenderedMessage:
|
|
282
|
+
parts = self._formatter.render_progress_parts(
|
|
283
|
+
state, elapsed_s=elapsed_s, label=label
|
|
284
|
+
)
|
|
285
|
+
return RenderedMessage(text=assemble_markdown_parts(parts))
|
|
286
|
+
|
|
287
|
+
def render_final(
|
|
288
|
+
self,
|
|
289
|
+
state: ProgressState,
|
|
290
|
+
*,
|
|
291
|
+
elapsed_s: float,
|
|
292
|
+
status: str,
|
|
293
|
+
answer: str,
|
|
294
|
+
) -> RenderedMessage:
|
|
295
|
+
parts = self._formatter.render_final_parts(
|
|
296
|
+
state, elapsed_s=elapsed_s, status=status, answer=answer
|
|
297
|
+
)
|
|
298
|
+
return RenderedMessage(text=assemble_markdown_parts(parts))
|
yee88/model.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Takopi domain model types (events, actions, resume tokens)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
type EngineId = str
|
|
9
|
+
|
|
10
|
+
type ActionKind = Literal[
|
|
11
|
+
"command",
|
|
12
|
+
"tool",
|
|
13
|
+
"file_change",
|
|
14
|
+
"web_search",
|
|
15
|
+
"subagent",
|
|
16
|
+
"note",
|
|
17
|
+
"turn",
|
|
18
|
+
"warning",
|
|
19
|
+
"telemetry",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
type TakopiEventType = Literal[
|
|
23
|
+
"started",
|
|
24
|
+
"action",
|
|
25
|
+
"completed",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
type ActionPhase = Literal["started", "updated", "completed"]
|
|
29
|
+
type ActionLevel = Literal["debug", "info", "warning", "error"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class ResumeToken:
|
|
34
|
+
engine: EngineId
|
|
35
|
+
value: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class Action:
|
|
40
|
+
id: str
|
|
41
|
+
kind: ActionKind
|
|
42
|
+
title: str
|
|
43
|
+
detail: dict[str, Any] = field(default_factory=dict)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class StartedEvent:
|
|
48
|
+
type: Literal["started"] = field(default="started", init=False)
|
|
49
|
+
engine: EngineId
|
|
50
|
+
resume: ResumeToken
|
|
51
|
+
title: str | None = None
|
|
52
|
+
meta: dict[str, Any] | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class ActionEvent:
|
|
57
|
+
type: Literal["action"] = field(default="action", init=False)
|
|
58
|
+
engine: EngineId
|
|
59
|
+
action: Action
|
|
60
|
+
phase: ActionPhase
|
|
61
|
+
ok: bool | None = None
|
|
62
|
+
message: str | None = None
|
|
63
|
+
level: ActionLevel | None = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class CompletedEvent:
|
|
68
|
+
type: Literal["completed"] = field(default="completed", init=False)
|
|
69
|
+
engine: EngineId
|
|
70
|
+
ok: bool
|
|
71
|
+
answer: str
|
|
72
|
+
resume: ResumeToken | None = None
|
|
73
|
+
error: str | None = None
|
|
74
|
+
usage: dict[str, Any] | None = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
type TakopiEvent = StartedEvent | ActionEvent | CompletedEvent
|
yee88/plugins.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from importlib.metadata import EntryPoint, entry_points
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
|
|
10
|
+
from .ids import ID_PATTERN, is_valid_id
|
|
11
|
+
|
|
12
|
+
ENGINE_GROUP = "yee88.engine_backends"
|
|
13
|
+
TRANSPORT_GROUP = "yee88.transport_backends"
|
|
14
|
+
COMMAND_GROUP = "yee88.command_backends"
|
|
15
|
+
|
|
16
|
+
_CANONICAL_NAME_RE = re.compile(r"[-_.]+")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class PluginLoadError:
|
|
21
|
+
group: str
|
|
22
|
+
name: str
|
|
23
|
+
value: str
|
|
24
|
+
distribution: str | None
|
|
25
|
+
error: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PluginLoadFailed(RuntimeError):
|
|
29
|
+
def __init__(self, error: PluginLoadError) -> None:
|
|
30
|
+
super().__init__(error.error)
|
|
31
|
+
self.error = error
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PluginNotFound(LookupError):
|
|
35
|
+
def __init__(self, group: str, name: str, available: Iterable[str]) -> None:
|
|
36
|
+
self.group = group
|
|
37
|
+
self.name = name
|
|
38
|
+
self.available = tuple(sorted(available))
|
|
39
|
+
message = f"{group} plugin {name!r} not found"
|
|
40
|
+
if self.available:
|
|
41
|
+
message = f"{message}. Available: {', '.join(self.available)}."
|
|
42
|
+
super().__init__(message)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_LOAD_ERRORS: dict[tuple[str, str, str, str | None, str], PluginLoadError] = {}
|
|
46
|
+
_LOADED: dict[tuple[str, str], Any] = {}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _error_key(error: PluginLoadError) -> tuple[str, str, str, str | None, str]:
|
|
50
|
+
return (error.group, error.name, error.value, error.distribution, error.error)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _record_error(error: PluginLoadError) -> None:
|
|
54
|
+
key = _error_key(error)
|
|
55
|
+
_LOAD_ERRORS.setdefault(key, error)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_load_errors() -> tuple[PluginLoadError, ...]:
|
|
59
|
+
return tuple(_LOAD_ERRORS.values())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def clear_load_errors(*, group: str | None = None, name: str | None = None) -> None:
|
|
63
|
+
if group is None and name is None:
|
|
64
|
+
_LOAD_ERRORS.clear()
|
|
65
|
+
return
|
|
66
|
+
remaining: dict[tuple[str, str, str, str | None, str], PluginLoadError] = {}
|
|
67
|
+
for key, error in _LOAD_ERRORS.items():
|
|
68
|
+
if group is not None and error.group != group:
|
|
69
|
+
remaining[key] = error
|
|
70
|
+
continue
|
|
71
|
+
if name is not None and error.name != name:
|
|
72
|
+
remaining[key] = error
|
|
73
|
+
continue
|
|
74
|
+
_LOAD_ERRORS.clear()
|
|
75
|
+
_LOAD_ERRORS.update(remaining)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def reset_plugin_state() -> None:
|
|
79
|
+
clear_load_errors()
|
|
80
|
+
_LOADED.clear()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _select_entrypoints(group: str) -> list[EntryPoint]:
|
|
84
|
+
return list(entry_points().select(group=group))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def entrypoint_distribution_name(ep: EntryPoint) -> str | None:
|
|
88
|
+
dist = getattr(ep, "dist", None)
|
|
89
|
+
if dist is None:
|
|
90
|
+
return None
|
|
91
|
+
name = getattr(dist, "name", None)
|
|
92
|
+
if name:
|
|
93
|
+
return name
|
|
94
|
+
metadata = getattr(dist, "metadata", None)
|
|
95
|
+
if metadata is None:
|
|
96
|
+
return None
|
|
97
|
+
try:
|
|
98
|
+
return metadata["Name"]
|
|
99
|
+
except (KeyError, TypeError):
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def normalize_allowlist(allowlist: Iterable[str] | None) -> set[str] | None:
|
|
104
|
+
if allowlist is None:
|
|
105
|
+
return None
|
|
106
|
+
cleaned = {
|
|
107
|
+
_CANONICAL_NAME_RE.sub("-", item.strip()).lower()
|
|
108
|
+
for item in allowlist
|
|
109
|
+
if item and item.strip()
|
|
110
|
+
}
|
|
111
|
+
return cleaned or None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def is_entrypoint_allowed(ep: EntryPoint, allowlist: set[str] | None) -> bool:
|
|
115
|
+
if allowlist is None:
|
|
116
|
+
return True
|
|
117
|
+
dist_name = entrypoint_distribution_name(ep)
|
|
118
|
+
if dist_name is None:
|
|
119
|
+
return False
|
|
120
|
+
return _CANONICAL_NAME_RE.sub("-", dist_name).lower() in allowlist
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _entrypoint_sort_key(ep: EntryPoint) -> tuple[str, str, str]:
|
|
124
|
+
dist = entrypoint_distribution_name(ep) or ""
|
|
125
|
+
return (ep.name, dist, ep.value)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _normalize_reserved(reserved: Iterable[str] | None) -> set[str] | None:
|
|
129
|
+
if reserved is None:
|
|
130
|
+
return None
|
|
131
|
+
cleaned = {item.strip().lower() for item in reserved if item and item.strip()}
|
|
132
|
+
return cleaned or None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _discover_entrypoints(
|
|
136
|
+
group: str,
|
|
137
|
+
*,
|
|
138
|
+
allowlist: Iterable[str] | None = None,
|
|
139
|
+
reserved_ids: Iterable[str] | None = None,
|
|
140
|
+
) -> tuple[dict[str, EntryPoint], dict[str, list[EntryPoint]]]:
|
|
141
|
+
allow = normalize_allowlist(allowlist)
|
|
142
|
+
reserved = _normalize_reserved(reserved_ids)
|
|
143
|
+
raw_eps = _select_entrypoints(group)
|
|
144
|
+
eps = [ep for ep in raw_eps if is_entrypoint_allowed(ep, allow)]
|
|
145
|
+
eps.sort(key=_entrypoint_sort_key)
|
|
146
|
+
|
|
147
|
+
by_name: dict[str, EntryPoint] = {}
|
|
148
|
+
duplicates: dict[str, list[EntryPoint]] = {}
|
|
149
|
+
|
|
150
|
+
for ep in eps:
|
|
151
|
+
if not is_valid_id(ep.name):
|
|
152
|
+
_record_error(
|
|
153
|
+
PluginLoadError(
|
|
154
|
+
group=group,
|
|
155
|
+
name=ep.name,
|
|
156
|
+
value=ep.value,
|
|
157
|
+
distribution=entrypoint_distribution_name(ep),
|
|
158
|
+
error=(f"invalid plugin id {ep.name!r}; must match {ID_PATTERN}"),
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
continue
|
|
162
|
+
if reserved is not None and ep.name.lower() in reserved:
|
|
163
|
+
_record_error(
|
|
164
|
+
PluginLoadError(
|
|
165
|
+
group=group,
|
|
166
|
+
name=ep.name,
|
|
167
|
+
value=ep.value,
|
|
168
|
+
distribution=entrypoint_distribution_name(ep),
|
|
169
|
+
error=f"reserved plugin id {ep.name!r} is not allowed",
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
continue
|
|
173
|
+
existing = by_name.get(ep.name)
|
|
174
|
+
if existing is None:
|
|
175
|
+
by_name[ep.name] = ep
|
|
176
|
+
continue
|
|
177
|
+
duplicates.setdefault(ep.name, [existing]).append(ep)
|
|
178
|
+
|
|
179
|
+
for name, items in duplicates.items():
|
|
180
|
+
providers = ", ".join(
|
|
181
|
+
sorted(
|
|
182
|
+
{entrypoint_distribution_name(item) or "<unknown>" for item in items}
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
message = f"duplicate plugin id {name!r} from {providers}"
|
|
186
|
+
for item in items:
|
|
187
|
+
_record_error(
|
|
188
|
+
PluginLoadError(
|
|
189
|
+
group=group,
|
|
190
|
+
name=name,
|
|
191
|
+
value=item.value,
|
|
192
|
+
distribution=entrypoint_distribution_name(item),
|
|
193
|
+
error=message,
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
by_name.pop(name, None)
|
|
197
|
+
|
|
198
|
+
return by_name, duplicates
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def list_entrypoints(
|
|
202
|
+
group: str,
|
|
203
|
+
*,
|
|
204
|
+
allowlist: Iterable[str] | None = None,
|
|
205
|
+
reserved_ids: Iterable[str] | None = None,
|
|
206
|
+
) -> list[EntryPoint]:
|
|
207
|
+
by_name, _ = _discover_entrypoints(
|
|
208
|
+
group, allowlist=allowlist, reserved_ids=reserved_ids
|
|
209
|
+
)
|
|
210
|
+
return [by_name[name] for name in sorted(by_name)]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def list_ids(
|
|
214
|
+
group: str,
|
|
215
|
+
*,
|
|
216
|
+
allowlist: Iterable[str] | None = None,
|
|
217
|
+
reserved_ids: Iterable[str] | None = None,
|
|
218
|
+
) -> list[str]:
|
|
219
|
+
return sorted(
|
|
220
|
+
ep.name
|
|
221
|
+
for ep in list_entrypoints(
|
|
222
|
+
group, allowlist=allowlist, reserved_ids=reserved_ids
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def load_entrypoint(
|
|
228
|
+
group: str,
|
|
229
|
+
name: str,
|
|
230
|
+
*,
|
|
231
|
+
allowlist: Iterable[str] | None = None,
|
|
232
|
+
validator: Callable[[Any, EntryPoint], None] | None = None,
|
|
233
|
+
) -> Any:
|
|
234
|
+
by_name, duplicates = _discover_entrypoints(group, allowlist=allowlist)
|
|
235
|
+
if name in duplicates:
|
|
236
|
+
items = duplicates[name]
|
|
237
|
+
providers = ", ".join(
|
|
238
|
+
sorted(
|
|
239
|
+
{entrypoint_distribution_name(item) or "<unknown>" for item in items}
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
error = PluginLoadError(
|
|
243
|
+
group=group,
|
|
244
|
+
name=name,
|
|
245
|
+
value=items[0].value,
|
|
246
|
+
distribution=entrypoint_distribution_name(items[0]),
|
|
247
|
+
error=f"duplicate plugin id {name!r} from {providers}",
|
|
248
|
+
)
|
|
249
|
+
_record_error(error)
|
|
250
|
+
raise PluginLoadFailed(error)
|
|
251
|
+
|
|
252
|
+
ep = by_name.get(name)
|
|
253
|
+
if ep is None:
|
|
254
|
+
raise PluginNotFound(group, name, by_name)
|
|
255
|
+
|
|
256
|
+
key = (group, name)
|
|
257
|
+
if key in _LOADED:
|
|
258
|
+
return _LOADED[key]
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
loaded = ep.load()
|
|
262
|
+
if validator is not None:
|
|
263
|
+
validator(loaded, ep)
|
|
264
|
+
except PluginLoadFailed:
|
|
265
|
+
raise
|
|
266
|
+
except Exception as exc:
|
|
267
|
+
error = PluginLoadError(
|
|
268
|
+
group=group,
|
|
269
|
+
name=ep.name,
|
|
270
|
+
value=ep.value,
|
|
271
|
+
distribution=entrypoint_distribution_name(ep),
|
|
272
|
+
error=str(exc),
|
|
273
|
+
)
|
|
274
|
+
_record_error(error)
|
|
275
|
+
raise PluginLoadFailed(error) from exc
|
|
276
|
+
|
|
277
|
+
_LOADED[key] = loaded
|
|
278
|
+
clear_load_errors(group=group, name=name)
|
|
279
|
+
return loaded
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def load_plugin_backend(
|
|
283
|
+
group: str,
|
|
284
|
+
name: str,
|
|
285
|
+
*,
|
|
286
|
+
allowlist: Iterable[str] | None = None,
|
|
287
|
+
validator: Callable[[Any, EntryPoint], None] | None = None,
|
|
288
|
+
kind_label: str,
|
|
289
|
+
required: bool = True,
|
|
290
|
+
) -> Any | None:
|
|
291
|
+
try:
|
|
292
|
+
return load_entrypoint(
|
|
293
|
+
group,
|
|
294
|
+
name,
|
|
295
|
+
allowlist=allowlist,
|
|
296
|
+
validator=validator,
|
|
297
|
+
)
|
|
298
|
+
except PluginNotFound as exc:
|
|
299
|
+
if not required:
|
|
300
|
+
return None
|
|
301
|
+
if exc.available:
|
|
302
|
+
available = ", ".join(exc.available)
|
|
303
|
+
message = f"Unknown {kind_label} {name!r}. Available: {available}."
|
|
304
|
+
else:
|
|
305
|
+
message = f"Unknown {kind_label} {name!r}."
|
|
306
|
+
from .config import ConfigError
|
|
307
|
+
|
|
308
|
+
raise ConfigError(message) from exc
|
|
309
|
+
except PluginLoadFailed as exc:
|
|
310
|
+
from .config import ConfigError
|
|
311
|
+
|
|
312
|
+
raise ConfigError(f"Failed to load {kind_label} {name!r}: {exc}") from exc
|
yee88/presenter.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from .progress import ProgressState
|
|
6
|
+
from .transport import RenderedMessage
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Presenter(Protocol):
|
|
10
|
+
def render_progress(
|
|
11
|
+
self,
|
|
12
|
+
state: ProgressState,
|
|
13
|
+
*,
|
|
14
|
+
elapsed_s: float,
|
|
15
|
+
label: str = "working",
|
|
16
|
+
) -> RenderedMessage: ...
|
|
17
|
+
|
|
18
|
+
def render_final(
|
|
19
|
+
self,
|
|
20
|
+
state: ProgressState,
|
|
21
|
+
*,
|
|
22
|
+
elapsed_s: float,
|
|
23
|
+
status: str,
|
|
24
|
+
answer: str,
|
|
25
|
+
) -> RenderedMessage: ...
|