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.
Files changed (103) hide show
  1. yee88/__init__.py +1 -0
  2. yee88/api.py +116 -0
  3. yee88/backends.py +25 -0
  4. yee88/backends_helpers.py +14 -0
  5. yee88/cli/__init__.py +228 -0
  6. yee88/cli/config.py +320 -0
  7. yee88/cli/doctor.py +173 -0
  8. yee88/cli/init.py +113 -0
  9. yee88/cli/onboarding_cmd.py +126 -0
  10. yee88/cli/plugins.py +196 -0
  11. yee88/cli/run.py +419 -0
  12. yee88/cli/topic.py +355 -0
  13. yee88/commands.py +134 -0
  14. yee88/config.py +142 -0
  15. yee88/config_migrations.py +124 -0
  16. yee88/config_watch.py +146 -0
  17. yee88/context.py +9 -0
  18. yee88/directives.py +146 -0
  19. yee88/engines.py +53 -0
  20. yee88/events.py +170 -0
  21. yee88/ids.py +17 -0
  22. yee88/lockfile.py +158 -0
  23. yee88/logging.py +283 -0
  24. yee88/markdown.py +298 -0
  25. yee88/model.py +77 -0
  26. yee88/plugins.py +312 -0
  27. yee88/presenter.py +25 -0
  28. yee88/progress.py +99 -0
  29. yee88/router.py +113 -0
  30. yee88/runner.py +712 -0
  31. yee88/runner_bridge.py +619 -0
  32. yee88/runners/__init__.py +1 -0
  33. yee88/runners/claude.py +483 -0
  34. yee88/runners/codex.py +656 -0
  35. yee88/runners/mock.py +221 -0
  36. yee88/runners/opencode.py +505 -0
  37. yee88/runners/pi.py +523 -0
  38. yee88/runners/run_options.py +39 -0
  39. yee88/runners/tool_actions.py +90 -0
  40. yee88/runtime_loader.py +207 -0
  41. yee88/scheduler.py +159 -0
  42. yee88/schemas/__init__.py +1 -0
  43. yee88/schemas/claude.py +238 -0
  44. yee88/schemas/codex.py +169 -0
  45. yee88/schemas/opencode.py +51 -0
  46. yee88/schemas/pi.py +117 -0
  47. yee88/settings.py +360 -0
  48. yee88/telegram/__init__.py +20 -0
  49. yee88/telegram/api_models.py +37 -0
  50. yee88/telegram/api_schemas.py +152 -0
  51. yee88/telegram/backend.py +163 -0
  52. yee88/telegram/bridge.py +425 -0
  53. yee88/telegram/chat_prefs.py +242 -0
  54. yee88/telegram/chat_sessions.py +112 -0
  55. yee88/telegram/client.py +409 -0
  56. yee88/telegram/client_api.py +539 -0
  57. yee88/telegram/commands/__init__.py +12 -0
  58. yee88/telegram/commands/agent.py +196 -0
  59. yee88/telegram/commands/cancel.py +116 -0
  60. yee88/telegram/commands/dispatch.py +111 -0
  61. yee88/telegram/commands/executor.py +449 -0
  62. yee88/telegram/commands/file_transfer.py +586 -0
  63. yee88/telegram/commands/handlers.py +45 -0
  64. yee88/telegram/commands/media.py +143 -0
  65. yee88/telegram/commands/menu.py +139 -0
  66. yee88/telegram/commands/model.py +215 -0
  67. yee88/telegram/commands/overrides.py +159 -0
  68. yee88/telegram/commands/parse.py +30 -0
  69. yee88/telegram/commands/plan.py +16 -0
  70. yee88/telegram/commands/reasoning.py +234 -0
  71. yee88/telegram/commands/reply.py +23 -0
  72. yee88/telegram/commands/topics.py +332 -0
  73. yee88/telegram/commands/trigger.py +143 -0
  74. yee88/telegram/context.py +140 -0
  75. yee88/telegram/engine_defaults.py +86 -0
  76. yee88/telegram/engine_overrides.py +105 -0
  77. yee88/telegram/files.py +178 -0
  78. yee88/telegram/loop.py +1822 -0
  79. yee88/telegram/onboarding.py +1088 -0
  80. yee88/telegram/outbox.py +177 -0
  81. yee88/telegram/parsing.py +239 -0
  82. yee88/telegram/render.py +198 -0
  83. yee88/telegram/state_store.py +88 -0
  84. yee88/telegram/topic_state.py +334 -0
  85. yee88/telegram/topics.py +256 -0
  86. yee88/telegram/trigger_mode.py +68 -0
  87. yee88/telegram/types.py +63 -0
  88. yee88/telegram/voice.py +110 -0
  89. yee88/transport.py +53 -0
  90. yee88/transport_runtime.py +323 -0
  91. yee88/transports.py +76 -0
  92. yee88/utils/__init__.py +1 -0
  93. yee88/utils/git.py +87 -0
  94. yee88/utils/json_state.py +21 -0
  95. yee88/utils/paths.py +47 -0
  96. yee88/utils/streams.py +44 -0
  97. yee88/utils/subprocess.py +86 -0
  98. yee88/worktrees.py +135 -0
  99. yee88-0.3.0.dist-info/METADATA +116 -0
  100. yee88-0.3.0.dist-info/RECORD +103 -0
  101. yee88-0.3.0.dist-info/WHEEL +4 -0
  102. yee88-0.3.0.dist-info/entry_points.txt +11 -0
  103. 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: ...