yee88 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.
Files changed (103) hide show
  1. takopi/__init__.py +1 -0
  2. takopi/api.py +116 -0
  3. takopi/backends.py +25 -0
  4. takopi/backends_helpers.py +14 -0
  5. takopi/cli/__init__.py +228 -0
  6. takopi/cli/config.py +320 -0
  7. takopi/cli/doctor.py +173 -0
  8. takopi/cli/init.py +113 -0
  9. takopi/cli/onboarding_cmd.py +126 -0
  10. takopi/cli/plugins.py +196 -0
  11. takopi/cli/run.py +419 -0
  12. takopi/cli/topic.py +355 -0
  13. takopi/commands.py +134 -0
  14. takopi/config.py +142 -0
  15. takopi/config_migrations.py +124 -0
  16. takopi/config_watch.py +146 -0
  17. takopi/context.py +9 -0
  18. takopi/directives.py +146 -0
  19. takopi/engines.py +53 -0
  20. takopi/events.py +170 -0
  21. takopi/ids.py +17 -0
  22. takopi/lockfile.py +158 -0
  23. takopi/logging.py +283 -0
  24. takopi/markdown.py +298 -0
  25. takopi/model.py +77 -0
  26. takopi/plugins.py +312 -0
  27. takopi/presenter.py +25 -0
  28. takopi/progress.py +99 -0
  29. takopi/router.py +113 -0
  30. takopi/runner.py +712 -0
  31. takopi/runner_bridge.py +619 -0
  32. takopi/runners/__init__.py +1 -0
  33. takopi/runners/claude.py +483 -0
  34. takopi/runners/codex.py +656 -0
  35. takopi/runners/mock.py +221 -0
  36. takopi/runners/opencode.py +505 -0
  37. takopi/runners/pi.py +523 -0
  38. takopi/runners/run_options.py +39 -0
  39. takopi/runners/tool_actions.py +90 -0
  40. takopi/runtime_loader.py +207 -0
  41. takopi/scheduler.py +159 -0
  42. takopi/schemas/__init__.py +1 -0
  43. takopi/schemas/claude.py +238 -0
  44. takopi/schemas/codex.py +169 -0
  45. takopi/schemas/opencode.py +51 -0
  46. takopi/schemas/pi.py +117 -0
  47. takopi/settings.py +360 -0
  48. takopi/telegram/__init__.py +20 -0
  49. takopi/telegram/api_models.py +37 -0
  50. takopi/telegram/api_schemas.py +152 -0
  51. takopi/telegram/backend.py +163 -0
  52. takopi/telegram/bridge.py +425 -0
  53. takopi/telegram/chat_prefs.py +242 -0
  54. takopi/telegram/chat_sessions.py +112 -0
  55. takopi/telegram/client.py +409 -0
  56. takopi/telegram/client_api.py +539 -0
  57. takopi/telegram/commands/__init__.py +12 -0
  58. takopi/telegram/commands/agent.py +196 -0
  59. takopi/telegram/commands/cancel.py +116 -0
  60. takopi/telegram/commands/dispatch.py +111 -0
  61. takopi/telegram/commands/executor.py +449 -0
  62. takopi/telegram/commands/file_transfer.py +586 -0
  63. takopi/telegram/commands/handlers.py +45 -0
  64. takopi/telegram/commands/media.py +143 -0
  65. takopi/telegram/commands/menu.py +139 -0
  66. takopi/telegram/commands/model.py +215 -0
  67. takopi/telegram/commands/overrides.py +159 -0
  68. takopi/telegram/commands/parse.py +30 -0
  69. takopi/telegram/commands/plan.py +16 -0
  70. takopi/telegram/commands/reasoning.py +234 -0
  71. takopi/telegram/commands/reply.py +23 -0
  72. takopi/telegram/commands/topics.py +332 -0
  73. takopi/telegram/commands/trigger.py +143 -0
  74. takopi/telegram/context.py +140 -0
  75. takopi/telegram/engine_defaults.py +86 -0
  76. takopi/telegram/engine_overrides.py +105 -0
  77. takopi/telegram/files.py +178 -0
  78. takopi/telegram/loop.py +1822 -0
  79. takopi/telegram/onboarding.py +1088 -0
  80. takopi/telegram/outbox.py +177 -0
  81. takopi/telegram/parsing.py +239 -0
  82. takopi/telegram/render.py +198 -0
  83. takopi/telegram/state_store.py +88 -0
  84. takopi/telegram/topic_state.py +334 -0
  85. takopi/telegram/topics.py +256 -0
  86. takopi/telegram/trigger_mode.py +68 -0
  87. takopi/telegram/types.py +63 -0
  88. takopi/telegram/voice.py +110 -0
  89. takopi/transport.py +53 -0
  90. takopi/transport_runtime.py +323 -0
  91. takopi/transports.py +76 -0
  92. takopi/utils/__init__.py +1 -0
  93. takopi/utils/git.py +87 -0
  94. takopi/utils/json_state.py +21 -0
  95. takopi/utils/paths.py +47 -0
  96. takopi/utils/streams.py +44 -0
  97. takopi/utils/subprocess.py +86 -0
  98. takopi/worktrees.py +135 -0
  99. yee88-0.1.0.dist-info/METADATA +116 -0
  100. yee88-0.1.0.dist-info/RECORD +103 -0
  101. yee88-0.1.0.dist-info/WHEEL +4 -0
  102. yee88-0.1.0.dist-info/entry_points.txt +11 -0
  103. yee88-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class TelegramVoice:
9
+ file_id: str
10
+ mime_type: str | None
11
+ file_size: int | None
12
+ duration: int | None
13
+ raw: dict[str, Any]
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class TelegramDocument:
18
+ file_id: str
19
+ file_name: str | None
20
+ mime_type: str | None
21
+ file_size: int | None
22
+ raw: dict[str, Any]
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class TelegramIncomingMessage:
27
+ transport: str
28
+ chat_id: int
29
+ message_id: int
30
+ text: str
31
+ reply_to_message_id: int | None
32
+ reply_to_text: str | None
33
+ sender_id: int | None
34
+ reply_to_is_bot: bool | None = None
35
+ reply_to_username: str | None = None
36
+ media_group_id: str | None = None
37
+ thread_id: int | None = None
38
+ is_topic_message: bool | None = None
39
+ chat_type: str | None = None
40
+ is_forum: bool | None = None
41
+ voice: TelegramVoice | None = None
42
+ document: TelegramDocument | None = None
43
+ raw: dict[str, Any] | None = None
44
+
45
+ @property
46
+ def is_private(self) -> bool:
47
+ if self.chat_type is not None:
48
+ return self.chat_type == "private"
49
+ return self.chat_id > 0
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class TelegramCallbackQuery:
54
+ transport: str
55
+ chat_id: int
56
+ message_id: int
57
+ callback_query_id: str
58
+ data: str | None
59
+ sender_id: int | None
60
+ raw: dict[str, Any] | None = None
61
+
62
+
63
+ TelegramIncomingUpdate = TelegramIncomingMessage | TelegramCallbackQuery
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ from collections.abc import Awaitable, Callable
5
+ from typing import Protocol
6
+
7
+ from ..logging import get_logger
8
+ from openai import AsyncOpenAI, OpenAIError
9
+
10
+ from .client import BotClient
11
+ from .types import TelegramIncomingMessage
12
+
13
+ logger = get_logger(__name__)
14
+
15
+ __all__ = ["transcribe_voice"]
16
+
17
+ VOICE_TRANSCRIPTION_DISABLED_HINT = (
18
+ "voice transcription is disabled. enable it in config:\n"
19
+ "```toml\n"
20
+ "[transports.telegram]\n"
21
+ "voice_transcription = true\n"
22
+ "```"
23
+ )
24
+
25
+
26
+ class VoiceTranscriber(Protocol):
27
+ async def transcribe(self, *, model: str, audio_bytes: bytes) -> str: ...
28
+
29
+
30
+ class OpenAIVoiceTranscriber:
31
+ def __init__(
32
+ self,
33
+ *,
34
+ base_url: str | None = None,
35
+ api_key: str | None = None,
36
+ ) -> None:
37
+ self._base_url = base_url
38
+ self._api_key = api_key
39
+
40
+ async def transcribe(self, *, model: str, audio_bytes: bytes) -> str:
41
+ audio_file = io.BytesIO(audio_bytes)
42
+ audio_file.name = "voice.ogg"
43
+ async with AsyncOpenAI(
44
+ base_url=self._base_url,
45
+ api_key=self._api_key,
46
+ timeout=120,
47
+ ) as client:
48
+ response = await client.audio.transcriptions.create(
49
+ model=model,
50
+ file=audio_file,
51
+ )
52
+ return response.text
53
+
54
+
55
+ async def transcribe_voice(
56
+ *,
57
+ bot: BotClient,
58
+ msg: TelegramIncomingMessage,
59
+ enabled: bool,
60
+ model: str,
61
+ max_bytes: int | None = None,
62
+ reply: Callable[..., Awaitable[None]],
63
+ transcriber: VoiceTranscriber | None = None,
64
+ base_url: str | None = None,
65
+ api_key: str | None = None,
66
+ ) -> str | None:
67
+ voice = msg.voice
68
+ if voice is None:
69
+ return msg.text
70
+ if not enabled:
71
+ await reply(text=VOICE_TRANSCRIPTION_DISABLED_HINT)
72
+ return None
73
+ if (
74
+ max_bytes is not None
75
+ and voice.file_size is not None
76
+ and voice.file_size > max_bytes
77
+ ):
78
+ await reply(text="voice message is too large to transcribe.")
79
+ return None
80
+ file_info = await bot.get_file(voice.file_id)
81
+ if file_info is None:
82
+ await reply(text="failed to fetch voice file.")
83
+ return None
84
+ audio_bytes = await bot.download_file(file_info.file_path)
85
+ if audio_bytes is None:
86
+ await reply(text="failed to download voice file.")
87
+ return None
88
+ if max_bytes is not None and len(audio_bytes) > max_bytes:
89
+ await reply(text="voice message is too large to transcribe.")
90
+ return None
91
+ if transcriber is None:
92
+ transcriber = OpenAIVoiceTranscriber(base_url=base_url, api_key=api_key)
93
+ try:
94
+ return await transcriber.transcribe(model=model, audio_bytes=audio_bytes)
95
+ except OpenAIError as exc:
96
+ logger.error(
97
+ "openai.transcribe.error",
98
+ error=str(exc),
99
+ error_type=exc.__class__.__name__,
100
+ )
101
+ await reply(text=str(exc).strip() or "voice transcription failed")
102
+ return None
103
+ except (RuntimeError, OSError, ValueError) as exc:
104
+ logger.error(
105
+ "voice.transcribe.error",
106
+ error=str(exc),
107
+ error_type=exc.__class__.__name__,
108
+ )
109
+ await reply(text=str(exc).strip() or "voice transcription failed")
110
+ return None
takopi/transport.py ADDED
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Protocol
5
+
6
+ type ChannelId = int | str
7
+ type MessageId = int | str
8
+ type ThreadId = int | str
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class MessageRef:
13
+ channel_id: ChannelId
14
+ message_id: MessageId
15
+ raw: Any | None = field(default=None, compare=False, hash=False)
16
+ thread_id: ThreadId | None = field(default=None, compare=False, hash=False)
17
+ sender_id: int | None = field(default=None, compare=False, hash=False)
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class RenderedMessage:
22
+ text: str
23
+ extra: dict[str, Any] = field(default_factory=dict)
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class SendOptions:
28
+ reply_to: MessageRef | None = None
29
+ notify: bool = True
30
+ replace: MessageRef | None = None
31
+ thread_id: ThreadId | None = None
32
+
33
+
34
+ class Transport(Protocol):
35
+ async def close(self) -> None: ...
36
+
37
+ async def send(
38
+ self,
39
+ *,
40
+ channel_id: ChannelId,
41
+ message: RenderedMessage,
42
+ options: SendOptions | None = None,
43
+ ) -> MessageRef | None: ...
44
+
45
+ async def edit(
46
+ self,
47
+ *,
48
+ ref: MessageRef,
49
+ message: RenderedMessage,
50
+ wait: bool = True,
51
+ ) -> MessageRef | None: ...
52
+
53
+ async def delete(self, *, ref: MessageRef) -> bool: ...
@@ -0,0 +1,323 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable, Mapping
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any, Literal
7
+
8
+ from .config import ConfigError, ProjectsConfig
9
+ from .context import RunContext
10
+ from .directives import (
11
+ ParsedDirectives,
12
+ format_context_line,
13
+ parse_context_line,
14
+ parse_directives,
15
+ )
16
+ from .model import EngineId, ResumeToken
17
+ from .plugins import normalize_allowlist
18
+ from .router import AutoRouter, EngineStatus
19
+ from .runner import Runner
20
+ from .worktrees import WorktreeError, resolve_run_cwd
21
+
22
+ type ContextSource = Literal[
23
+ "reply_ctx",
24
+ "directives",
25
+ "ambient",
26
+ "default_project",
27
+ "none",
28
+ ]
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class ResolvedMessage:
33
+ prompt: str
34
+ resume_token: ResumeToken | None
35
+ engine_override: EngineId | None
36
+ context: RunContext | None
37
+ context_source: ContextSource = "none"
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class ResolvedRunner:
42
+ engine: EngineId
43
+ runner: Runner
44
+ available: bool
45
+ issue: str | None = None
46
+
47
+
48
+ class TransportRuntime:
49
+ __slots__ = (
50
+ "_router",
51
+ "_projects",
52
+ "_allowlist",
53
+ "_config_path",
54
+ "_plugin_configs",
55
+ "_watch_config",
56
+ )
57
+
58
+ def __init__(
59
+ self,
60
+ *,
61
+ router: AutoRouter,
62
+ projects: ProjectsConfig,
63
+ allowlist: Iterable[str] | None = None,
64
+ config_path: Path | None = None,
65
+ plugin_configs: Mapping[str, Any] | None = None,
66
+ watch_config: bool = False,
67
+ ) -> None:
68
+ self._apply(
69
+ router=router,
70
+ projects=projects,
71
+ allowlist=allowlist,
72
+ config_path=config_path,
73
+ plugin_configs=plugin_configs,
74
+ watch_config=watch_config,
75
+ )
76
+
77
+ def update(
78
+ self,
79
+ *,
80
+ router: AutoRouter,
81
+ projects: ProjectsConfig,
82
+ allowlist: Iterable[str] | None = None,
83
+ config_path: Path | None = None,
84
+ plugin_configs: Mapping[str, Any] | None = None,
85
+ watch_config: bool = False,
86
+ ) -> None:
87
+ self._apply(
88
+ router=router,
89
+ projects=projects,
90
+ allowlist=allowlist,
91
+ config_path=config_path,
92
+ plugin_configs=plugin_configs,
93
+ watch_config=watch_config,
94
+ )
95
+
96
+ def _apply(
97
+ self,
98
+ *,
99
+ router: AutoRouter,
100
+ projects: ProjectsConfig,
101
+ allowlist: Iterable[str] | None,
102
+ config_path: Path | None,
103
+ plugin_configs: Mapping[str, Any] | None,
104
+ watch_config: bool,
105
+ ) -> None:
106
+ self._router = router
107
+ self._projects = projects
108
+ self._allowlist = normalize_allowlist(allowlist)
109
+ self._config_path = config_path
110
+ self._plugin_configs = dict(plugin_configs or {})
111
+ self._watch_config = watch_config
112
+
113
+ @property
114
+ def default_engine(self) -> EngineId:
115
+ return self._router.default_engine
116
+
117
+ def resolve_engine(
118
+ self,
119
+ *,
120
+ engine_override: EngineId | None,
121
+ context: RunContext | None,
122
+ ) -> EngineId:
123
+ if engine_override is not None:
124
+ return engine_override
125
+ if context is None or context.project is None:
126
+ return self._router.default_engine
127
+ project = self._projects.projects.get(context.project)
128
+ if project is None:
129
+ return self._router.default_engine
130
+ return project.default_engine or self._router.default_engine
131
+
132
+ @property
133
+ def engine_ids(self) -> tuple[EngineId, ...]:
134
+ return self._router.engine_ids
135
+
136
+ def available_engine_ids(self) -> tuple[EngineId, ...]:
137
+ return tuple(entry.engine for entry in self._router.available_entries)
138
+
139
+ def engine_ids_with_status(self, status: EngineStatus) -> tuple[EngineId, ...]:
140
+ return tuple(
141
+ entry.engine for entry in self._router.entries if entry.status == status
142
+ )
143
+
144
+ def missing_engine_ids(self) -> tuple[EngineId, ...]:
145
+ return self.engine_ids_with_status("missing_cli")
146
+
147
+ def project_aliases(self) -> tuple[str, ...]:
148
+ return tuple(project.alias for project in self._projects.projects.values())
149
+
150
+ @property
151
+ def allowlist(self) -> set[str] | None:
152
+ return self._allowlist
153
+
154
+ @property
155
+ def config_path(self) -> Path | None:
156
+ return self._config_path
157
+
158
+ @property
159
+ def watch_config(self) -> bool:
160
+ return self._watch_config
161
+
162
+ def plugin_config(self, plugin_id: str) -> dict[str, Any]:
163
+ if not self._plugin_configs:
164
+ return {}
165
+ raw = self._plugin_configs.get(plugin_id)
166
+ if raw is None:
167
+ return {}
168
+ if not isinstance(raw, dict):
169
+ path = self._config_path or Path("<config>")
170
+ raise ConfigError(
171
+ f"Invalid `plugins.{plugin_id}` in {path}; expected a table."
172
+ )
173
+ return dict(raw)
174
+
175
+ def resolve_message(
176
+ self,
177
+ *,
178
+ text: str,
179
+ reply_text: str | None,
180
+ ambient_context: RunContext | None = None,
181
+ chat_id: int | None = None,
182
+ ) -> ResolvedMessage:
183
+ directives = parse_directives(
184
+ text,
185
+ engine_ids=self._router.engine_ids,
186
+ projects=self._projects,
187
+ )
188
+ reply_ctx = parse_context_line(reply_text, projects=self._projects)
189
+ resume_token = self._router.resolve_resume(directives.prompt, reply_text)
190
+ chat_project = self._projects.project_for_chat(chat_id)
191
+ default_project = chat_project or self._projects.default_project
192
+
193
+ context, context_source = self._resolve_context(
194
+ directives=directives,
195
+ reply_ctx=reply_ctx,
196
+ ambient_context=ambient_context,
197
+ default_project=default_project,
198
+ )
199
+ engine_override = self._resolve_engine_override(
200
+ directives_engine=directives.engine,
201
+ )
202
+
203
+ return ResolvedMessage(
204
+ prompt=directives.prompt,
205
+ resume_token=resume_token,
206
+ engine_override=engine_override,
207
+ context=context,
208
+ context_source=context_source,
209
+ )
210
+
211
+ def project_default_engine(self, context: RunContext | None) -> EngineId | None:
212
+ if context is None or context.project is None:
213
+ return None
214
+ project = self._projects.projects.get(context.project)
215
+ if project is None:
216
+ return None
217
+ return project.default_engine
218
+
219
+ def _resolve_context(
220
+ self,
221
+ *,
222
+ directives: ParsedDirectives,
223
+ reply_ctx: RunContext | None,
224
+ ambient_context: RunContext | None,
225
+ default_project: str | None,
226
+ ) -> tuple[RunContext | None, ContextSource]:
227
+ if reply_ctx is not None:
228
+ return reply_ctx, "reply_ctx"
229
+
230
+ project_key = directives.project
231
+ branch = directives.branch
232
+ if project_key is None:
233
+ if ambient_context is not None and ambient_context.project is not None:
234
+ project_key = ambient_context.project
235
+ else:
236
+ project_key = default_project
237
+ if (
238
+ branch is None
239
+ and ambient_context is not None
240
+ and ambient_context.branch is not None
241
+ and project_key == ambient_context.project
242
+ ):
243
+ branch = ambient_context.branch
244
+ context: RunContext | None = None
245
+ if project_key is not None or branch is not None:
246
+ context = RunContext(project=project_key, branch=branch)
247
+
248
+ if directives.project is not None or directives.branch is not None:
249
+ context_source: ContextSource = "directives"
250
+ elif ambient_context is not None and ambient_context.project is not None:
251
+ context_source = "ambient"
252
+ elif default_project is not None:
253
+ context_source = "default_project"
254
+ else:
255
+ context_source = "none"
256
+
257
+ return context, context_source
258
+
259
+ def _resolve_engine_override(
260
+ self,
261
+ *,
262
+ directives_engine: EngineId | None,
263
+ ) -> EngineId | None:
264
+ if directives_engine is not None:
265
+ return directives_engine
266
+ return None
267
+
268
+ @property
269
+ def default_project(self) -> str | None:
270
+ return self._projects.default_project
271
+
272
+ def normalize_project_key(self, value: str) -> str | None:
273
+ key = value.strip().lower()
274
+ if key in self._projects.projects:
275
+ return key
276
+ return None
277
+
278
+ def project_alias_for_key(self, key: str) -> str:
279
+ project = self._projects.projects.get(key)
280
+ return project.alias if project is not None else key
281
+
282
+ def default_context_for_chat(self, chat_id: int | None) -> RunContext | None:
283
+ project_key = self._projects.project_for_chat(chat_id)
284
+ if project_key is None:
285
+ return None
286
+ return RunContext(project=project_key, branch=None)
287
+
288
+ def resolve_system_prompt(self, context: RunContext | None) -> str | None:
289
+ project_key = context.project if context is not None else None
290
+ return self._projects.resolve_system_prompt(project_key)
291
+
292
+ def project_chat_ids(self) -> tuple[int, ...]:
293
+ return self._projects.project_chat_ids()
294
+
295
+ def resolve_runner(
296
+ self,
297
+ *,
298
+ resume_token: ResumeToken | None,
299
+ engine_override: EngineId | None,
300
+ ) -> ResolvedRunner:
301
+ entry = (
302
+ self._router.entry_for_engine(engine_override)
303
+ if resume_token is None
304
+ else self._router.entry_for(resume_token)
305
+ )
306
+ return ResolvedRunner(
307
+ engine=entry.engine,
308
+ runner=entry.runner,
309
+ available=entry.available,
310
+ issue=entry.issue,
311
+ )
312
+
313
+ def is_resume_line(self, line: str) -> bool:
314
+ return self._router.is_resume_line(line)
315
+
316
+ def resolve_run_cwd(self, context: RunContext | None) -> Path | None:
317
+ try:
318
+ return resolve_run_cwd(context, projects=self._projects)
319
+ except WorktreeError as exc:
320
+ raise ConfigError(str(exc)) from exc
321
+
322
+ def format_context_line(self, context: RunContext | None) -> str | None:
323
+ return format_context_line(context, projects=self._projects)
takopi/transports.py ADDED
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Protocol, runtime_checkable
6
+ from collections.abc import Iterable
7
+
8
+ from .backends import EngineBackend, SetupIssue
9
+ from .plugins import TRANSPORT_GROUP, list_ids, load_plugin_backend
10
+ from .transport_runtime import TransportRuntime
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class SetupResult:
15
+ issues: list[SetupIssue]
16
+ config_path: Path
17
+
18
+ @property
19
+ def ok(self) -> bool:
20
+ return not self.issues
21
+
22
+
23
+ @runtime_checkable
24
+ class TransportBackend(Protocol):
25
+ id: str
26
+ description: str
27
+
28
+ def check_setup(
29
+ self,
30
+ engine_backend: EngineBackend,
31
+ *,
32
+ transport_override: str | None = None,
33
+ ) -> SetupResult: ...
34
+
35
+ async def interactive_setup(self, *, force: bool) -> bool: ...
36
+
37
+ def lock_token(
38
+ self, *, transport_config: object, _config_path: Path
39
+ ) -> str | None: ...
40
+
41
+ def build_and_run(
42
+ self,
43
+ *,
44
+ transport_config: object,
45
+ config_path: Path,
46
+ runtime: TransportRuntime,
47
+ final_notify: bool,
48
+ default_engine_override: str | None,
49
+ ) -> None: ...
50
+
51
+
52
+ def _validate_transport_backend(backend: object, ep) -> None:
53
+ if not isinstance(backend, TransportBackend):
54
+ raise TypeError(f"{ep.value} is not a TransportBackend")
55
+ if backend.id != ep.name:
56
+ raise ValueError(
57
+ f"{ep.value} transport id {backend.id!r} does not match entrypoint {ep.name!r}"
58
+ )
59
+
60
+
61
+ def get_transport(
62
+ transport_id: str, *, allowlist: Iterable[str] | None = None
63
+ ) -> TransportBackend:
64
+ backend = load_plugin_backend(
65
+ TRANSPORT_GROUP,
66
+ transport_id,
67
+ allowlist=allowlist,
68
+ validator=_validate_transport_backend,
69
+ kind_label="transport",
70
+ )
71
+ assert backend is not None
72
+ return backend
73
+
74
+
75
+ def list_transports(*, allowlist: Iterable[str] | None = None) -> list[str]:
76
+ return list_ids(TRANSPORT_GROUP, allowlist=allowlist)
@@ -0,0 +1 @@
1
+ """Utility helpers for Takopi."""