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
takopi/cli/doctor.py ADDED
@@ -0,0 +1,173 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ from collections.abc import Awaitable, Callable
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Literal
9
+
10
+ import anyio
11
+ import typer
12
+
13
+ from ..config import ConfigError
14
+ from ..engines import list_backend_ids
15
+ from ..ids import RESERVED_CHAT_COMMANDS
16
+ from ..runtime_loader import resolve_plugins_allowlist
17
+ from ..settings import TakopiSettings, TelegramTopicsSettings
18
+ from ..telegram.client import TelegramClient
19
+ from ..telegram.topics import _validate_topics_setup_for
20
+
21
+ DoctorStatus = Literal["ok", "warning", "error"]
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class DoctorCheck:
26
+ label: str
27
+ status: DoctorStatus
28
+ detail: str | None = None
29
+
30
+ def render(self) -> str:
31
+ if self.detail:
32
+ return f"- {self.label}: {self.status} ({self.detail})"
33
+ return f"- {self.label}: {self.status}"
34
+
35
+
36
+ def _doctor_file_checks(settings: TakopiSettings) -> list[DoctorCheck]:
37
+ files = settings.transports.telegram.files
38
+ if not files.enabled:
39
+ return [DoctorCheck("file transfer", "ok", "disabled")]
40
+ if files.allowed_user_ids:
41
+ count = len(files.allowed_user_ids)
42
+ detail = f"restricted to {count} user id(s)"
43
+ return [DoctorCheck("file transfer", "ok", detail)]
44
+ return [DoctorCheck("file transfer", "warning", "enabled for all users")]
45
+
46
+
47
+ def _doctor_voice_checks(settings: TakopiSettings) -> list[DoctorCheck]:
48
+ if not settings.transports.telegram.voice_transcription:
49
+ return [DoctorCheck("voice transcription", "ok", "disabled")]
50
+ api_key = settings.transports.telegram.voice_transcription_api_key
51
+ if api_key:
52
+ return [
53
+ DoctorCheck("voice transcription", "ok", "voice_transcription_api_key set")
54
+ ]
55
+ if os.environ.get("OPENAI_API_KEY"):
56
+ return [DoctorCheck("voice transcription", "ok", "OPENAI_API_KEY set")]
57
+ return [DoctorCheck("voice transcription", "error", "API key not set")]
58
+
59
+
60
+ async def _doctor_telegram_checks(
61
+ token: str,
62
+ chat_id: int,
63
+ topics: TelegramTopicsSettings,
64
+ project_chat_ids: tuple[int, ...],
65
+ ) -> list[DoctorCheck]:
66
+ checks: list[DoctorCheck] = []
67
+ client_factory = _resolve_cli_attr("TelegramClient") or TelegramClient
68
+ validate_topics = (
69
+ _resolve_cli_attr("_validate_topics_setup_for") or _validate_topics_setup_for
70
+ )
71
+ bot = client_factory(token)
72
+ try:
73
+ me = await bot.get_me()
74
+ if me is None:
75
+ checks.append(
76
+ DoctorCheck("telegram token", "error", "failed to fetch bot info")
77
+ )
78
+ checks.append(DoctorCheck("chat_id", "error", "skipped (token invalid)"))
79
+ if topics.enabled:
80
+ checks.append(DoctorCheck("topics", "error", "skipped (token invalid)"))
81
+ else:
82
+ checks.append(DoctorCheck("topics", "ok", "disabled"))
83
+ return checks
84
+ bot_label = f"@{me.username}" if me.username else f"id={me.id}"
85
+ checks.append(DoctorCheck("telegram token", "ok", bot_label))
86
+ chat = await bot.get_chat(chat_id)
87
+ if chat is None:
88
+ checks.append(DoctorCheck("chat_id", "error", f"unreachable ({chat_id})"))
89
+ else:
90
+ checks.append(DoctorCheck("chat_id", "ok", f"{chat.type} ({chat_id})"))
91
+ if topics.enabled:
92
+ try:
93
+ await validate_topics(
94
+ bot=bot,
95
+ topics=topics,
96
+ chat_id=chat_id,
97
+ project_chat_ids=project_chat_ids,
98
+ )
99
+ checks.append(DoctorCheck("topics", "ok", f"scope={topics.scope}"))
100
+ except ConfigError as exc:
101
+ checks.append(DoctorCheck("topics", "error", str(exc)))
102
+ else:
103
+ checks.append(DoctorCheck("topics", "ok", "disabled"))
104
+ except Exception as exc: # noqa: BLE001
105
+ checks.append(DoctorCheck("telegram", "error", str(exc)))
106
+ finally:
107
+ await bot.close()
108
+ return checks
109
+
110
+
111
+ def run_doctor(
112
+ *,
113
+ load_settings_fn: Callable[[], tuple[TakopiSettings, Path]],
114
+ telegram_checks: Callable[
115
+ [str, int, TelegramTopicsSettings, tuple[int, ...]],
116
+ Awaitable[list[DoctorCheck]],
117
+ ],
118
+ file_checks: Callable[[TakopiSettings], list[DoctorCheck]],
119
+ voice_checks: Callable[[TakopiSettings], list[DoctorCheck]],
120
+ ) -> None:
121
+ try:
122
+ settings, config_path = load_settings_fn()
123
+ except ConfigError as exc:
124
+ typer.echo(f"error: {exc}", err=True)
125
+ raise typer.Exit(code=1) from exc
126
+
127
+ if settings.transport != "telegram":
128
+ typer.echo(
129
+ "error: takopi doctor currently supports the telegram transport only.",
130
+ err=True,
131
+ )
132
+ raise typer.Exit(code=1)
133
+
134
+ allowlist = resolve_plugins_allowlist(settings)
135
+ engine_ids = list_backend_ids(allowlist=allowlist)
136
+ try:
137
+ projects_cfg = settings.to_projects_config(
138
+ config_path=config_path,
139
+ engine_ids=engine_ids,
140
+ reserved=RESERVED_CHAT_COMMANDS,
141
+ )
142
+ except ConfigError as exc:
143
+ typer.echo(f"error: {exc}", err=True)
144
+ raise typer.Exit(code=1) from exc
145
+
146
+ tg = settings.transports.telegram
147
+ project_chat_ids = projects_cfg.project_chat_ids()
148
+ telegram_checks_result = anyio.run(
149
+ telegram_checks,
150
+ tg.bot_token,
151
+ tg.chat_id,
152
+ tg.topics,
153
+ project_chat_ids,
154
+ )
155
+ if telegram_checks_result is None:
156
+ telegram_checks_result = []
157
+ checks = [
158
+ *telegram_checks_result,
159
+ *file_checks(settings),
160
+ *voice_checks(settings),
161
+ ]
162
+ typer.echo("takopi doctor")
163
+ for check in checks:
164
+ typer.echo(check.render())
165
+ if any(check.status == "error" for check in checks):
166
+ raise typer.Exit(code=1)
167
+
168
+
169
+ def _resolve_cli_attr(name: str) -> object | None:
170
+ cli_module = sys.modules.get("takopi.cli")
171
+ if cli_module is None:
172
+ return None
173
+ return getattr(cli_module, name, None)
takopi/cli/init.py ADDED
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ from ..config import ConfigError, write_config
9
+ from ..config_migrations import migrate_config
10
+ from ..ids import RESERVED_CHAT_COMMANDS
11
+ from ..settings import TakopiSettings, validate_settings_data
12
+ from .config import _config_path_display
13
+
14
+
15
+ def _prompt_alias(value: str | None, *, default_alias: str | None = None) -> str:
16
+ if value is not None:
17
+ alias = value
18
+ elif default_alias:
19
+ alias = typer.prompt("project alias", default=default_alias)
20
+ else:
21
+ alias = typer.prompt("project alias")
22
+ alias = alias.strip()
23
+ if not alias:
24
+ typer.echo("error: project alias cannot be empty", err=True)
25
+ raise typer.Exit(code=1)
26
+ return alias
27
+
28
+
29
+ def _default_alias_from_path(path: Path) -> str | None:
30
+ name = path.name
31
+ if not name:
32
+ return None
33
+ name = name.removesuffix(".git")
34
+ return name or None
35
+
36
+
37
+ def _ensure_projects_table(config: dict, config_path: Path) -> dict:
38
+ projects = config.setdefault("projects", {})
39
+ if not isinstance(projects, dict):
40
+ raise ConfigError(f"Invalid `projects` in {config_path}; expected a table.")
41
+ return projects
42
+
43
+
44
+ def run_init(
45
+ *,
46
+ alias: str | None,
47
+ default: bool,
48
+ load_or_init_config_fn: Callable[[], tuple[dict, Path]],
49
+ resolve_main_worktree_root_fn: Callable[[Path], Path | None],
50
+ resolve_default_base_fn: Callable[[Path], str | None],
51
+ list_backend_ids_fn: Callable[..., list[str]],
52
+ resolve_plugins_allowlist_fn: Callable[[TakopiSettings], list[str] | None],
53
+ ) -> None:
54
+ config, config_path = load_or_init_config_fn()
55
+ if config_path.exists():
56
+ applied = migrate_config(config, config_path=config_path)
57
+ if applied:
58
+ write_config(config, config_path)
59
+
60
+ cwd = Path.cwd()
61
+ project_path = resolve_main_worktree_root_fn(cwd) or cwd
62
+ default_alias = _default_alias_from_path(project_path)
63
+ alias = _prompt_alias(alias, default_alias=default_alias)
64
+
65
+ settings = validate_settings_data(config, config_path=config_path)
66
+ allowlist = resolve_plugins_allowlist_fn(settings)
67
+ engine_ids = list_backend_ids_fn(allowlist=allowlist)
68
+ projects_cfg = settings.to_projects_config(
69
+ config_path=config_path,
70
+ engine_ids=engine_ids,
71
+ reserved=RESERVED_CHAT_COMMANDS,
72
+ )
73
+
74
+ alias_key = alias.lower()
75
+ if alias_key in {engine.lower() for engine in engine_ids}:
76
+ raise ConfigError(
77
+ f"Invalid project alias {alias!r}; aliases must not match engine ids."
78
+ )
79
+ if alias_key in RESERVED_CHAT_COMMANDS:
80
+ raise ConfigError(
81
+ f"Invalid project alias {alias!r}; aliases must not match reserved commands."
82
+ )
83
+
84
+ existing = projects_cfg.projects.get(alias_key)
85
+ if existing is not None:
86
+ overwrite = typer.confirm(
87
+ f"project {existing.alias!r} already exists, overwrite?",
88
+ default=False,
89
+ )
90
+ if not overwrite:
91
+ raise typer.Exit(code=1)
92
+
93
+ projects = _ensure_projects_table(config, config_path)
94
+ if existing is not None and existing.alias in projects:
95
+ projects.pop(existing.alias, None)
96
+
97
+ default_engine = settings.default_engine
98
+ worktree_base = resolve_default_base_fn(project_path)
99
+
100
+ entry: dict[str, object] = {
101
+ "path": str(project_path),
102
+ "worktrees_dir": ".worktrees",
103
+ "default_engine": default_engine,
104
+ }
105
+ if worktree_base:
106
+ entry["worktree_base"] = worktree_base
107
+
108
+ projects[alias] = entry
109
+ if default:
110
+ config["default_project"] = alias
111
+
112
+ write_config(config, config_path)
113
+ typer.echo(f"saved project {alias!r} to {_config_path_display(config_path)}")
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from collections.abc import Callable
5
+ from functools import partial
6
+ from pathlib import Path
7
+ from typing import Any, cast
8
+
9
+ import anyio
10
+ import typer
11
+
12
+ from ..config import ConfigError, load_or_init_config, write_config
13
+ from ..config_migrations import migrate_config
14
+ from ..logging import setup_logging
15
+ from ..settings import TakopiSettings
16
+ from ..telegram import onboarding
17
+ from .init import _ensure_projects_table
18
+ from .run import _load_settings_optional
19
+
20
+
21
+ def chat_id(
22
+ token: str | None = typer.Option(
23
+ None,
24
+ "--token",
25
+ help="Telegram bot token (defaults to config if available).",
26
+ ),
27
+ project: str | None = typer.Option(
28
+ None,
29
+ "--project",
30
+ help="Project alias to print a chat_id snippet for.",
31
+ ),
32
+ ) -> None:
33
+ """Capture a Telegram chat id and exit."""
34
+ setup_logging_fn = cast(
35
+ Callable[..., None],
36
+ _resolve_cli_attr("setup_logging") or setup_logging,
37
+ )
38
+ load_settings_optional_fn = cast(
39
+ Callable[[], tuple[TakopiSettings | None, Path | None]],
40
+ _resolve_cli_attr("_load_settings_optional") or _load_settings_optional,
41
+ )
42
+ onboarding_mod = cast(
43
+ Any,
44
+ _resolve_cli_attr("onboarding") or onboarding,
45
+ )
46
+ load_or_init_config_fn = cast(
47
+ Callable[[], tuple[dict, Path]],
48
+ _resolve_cli_attr("load_or_init_config") or load_or_init_config,
49
+ )
50
+ ensure_projects_table_fn = cast(
51
+ Callable[[dict, Path], dict],
52
+ _resolve_cli_attr("_ensure_projects_table") or _ensure_projects_table,
53
+ )
54
+ migrate_config_fn = cast(
55
+ Callable[..., object],
56
+ _resolve_cli_attr("migrate_config") or migrate_config,
57
+ )
58
+ write_config_fn = cast(
59
+ Callable[[dict, Path], None],
60
+ _resolve_cli_attr("write_config") or write_config,
61
+ )
62
+
63
+ setup_logging_fn(debug=False, cache_logger_on_first_use=False)
64
+ if token is None:
65
+ settings, _ = load_settings_optional_fn()
66
+ if settings is not None:
67
+ tg = settings.transports.telegram
68
+ token = tg.bot_token or None
69
+ chat = anyio.run(partial(onboarding_mod.capture_chat_id, token=token))
70
+ if chat is None:
71
+ raise typer.Exit(code=1)
72
+ if project:
73
+ project = project.strip()
74
+ if not project:
75
+ raise ConfigError("Invalid `--project`; expected a non-empty string.")
76
+
77
+ config, config_path = load_or_init_config_fn()
78
+ if config_path.exists():
79
+ applied = migrate_config_fn(config, config_path=config_path)
80
+ if applied:
81
+ write_config_fn(config, config_path)
82
+
83
+ projects = ensure_projects_table_fn(config, config_path)
84
+ entry = projects.get(project)
85
+ if entry is None:
86
+ lowered = project.lower()
87
+ for key, value in projects.items():
88
+ if isinstance(key, str) and key.lower() == lowered:
89
+ entry = value
90
+ project = key
91
+ break
92
+ if entry is None:
93
+ raise ConfigError(
94
+ f"Unknown project {project!r}; run `takopi init {project}` first."
95
+ )
96
+ if not isinstance(entry, dict):
97
+ raise ConfigError(
98
+ f"Invalid `projects.{project}` in {config_path}; expected a table."
99
+ )
100
+ entry["chat_id"] = chat.chat_id
101
+ write_config_fn(config, config_path)
102
+ typer.echo(f"updated projects.{project}.chat_id = {chat.chat_id}")
103
+ return
104
+
105
+ typer.echo(f"chat_id = {chat.chat_id}")
106
+
107
+
108
+ def onboarding_paths() -> None:
109
+ """Print all possible onboarding paths."""
110
+ setup_logging_fn = cast(
111
+ Callable[..., None],
112
+ _resolve_cli_attr("setup_logging") or setup_logging,
113
+ )
114
+ onboarding_mod = cast(
115
+ Any,
116
+ _resolve_cli_attr("onboarding") or onboarding,
117
+ )
118
+ setup_logging_fn(debug=False, cache_logger_on_first_use=False)
119
+ onboarding_mod.debug_onboarding_paths()
120
+
121
+
122
+ def _resolve_cli_attr(name: str) -> object | None:
123
+ cli_module = sys.modules.get("takopi.cli")
124
+ if cli_module is None:
125
+ return None
126
+ return getattr(cli_module, name, None)
takopi/cli/plugins.py ADDED
@@ -0,0 +1,196 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from collections.abc import Callable
5
+ from importlib.metadata import EntryPoint
6
+ from pathlib import Path
7
+ from typing import cast
8
+
9
+ import typer
10
+
11
+ from ..commands import get_command
12
+ from ..config import ConfigError
13
+ from ..engines import get_backend
14
+ from ..ids import RESERVED_COMMAND_IDS, RESERVED_ENGINE_IDS
15
+ from ..plugins import (
16
+ COMMAND_GROUP,
17
+ ENGINE_GROUP,
18
+ PluginLoadError,
19
+ TRANSPORT_GROUP,
20
+ entrypoint_distribution_name,
21
+ get_load_errors,
22
+ is_entrypoint_allowed,
23
+ list_entrypoints,
24
+ normalize_allowlist,
25
+ )
26
+ from ..runtime_loader import resolve_plugins_allowlist
27
+ from ..settings import TakopiSettings, load_settings_if_exists
28
+ from ..transports import get_transport
29
+
30
+
31
+ def _load_settings_optional() -> tuple[TakopiSettings | None, Path | None]:
32
+ try:
33
+ loaded = load_settings_if_exists()
34
+ except ConfigError:
35
+ return None, None
36
+ if loaded is None:
37
+ return None, None
38
+ return loaded
39
+
40
+
41
+ def _print_entrypoints(
42
+ label: str,
43
+ entrypoints: list[EntryPoint],
44
+ *,
45
+ allowlist: set[str] | None,
46
+ entrypoint_distribution_name_fn: Callable[[EntryPoint], str | None],
47
+ is_entrypoint_allowed_fn: Callable[[EntryPoint, set[str] | None], bool],
48
+ ) -> None:
49
+ typer.echo(f"{label}:")
50
+ if not entrypoints:
51
+ typer.echo(" (none)")
52
+ return
53
+ for ep in entrypoints:
54
+ dist = entrypoint_distribution_name_fn(ep) or "unknown"
55
+ status = ""
56
+ if allowlist is not None:
57
+ allowed = is_entrypoint_allowed_fn(ep, allowlist)
58
+ status = " enabled" if allowed else " disabled"
59
+ typer.echo(f" {ep.name} ({dist}){status}")
60
+
61
+
62
+ def plugins_cmd(
63
+ load: bool = typer.Option(
64
+ False,
65
+ "--load/--no-load",
66
+ help="Load plugins to validate and surface import errors.",
67
+ ),
68
+ ) -> None:
69
+ """List discovered plugins and optionally validate them."""
70
+ load_settings_optional = cast(
71
+ Callable[[], tuple[TakopiSettings | None, Path | None]],
72
+ _resolve_cli_attr("_load_settings_optional") or _load_settings_optional,
73
+ )
74
+ resolve_plugins_allowlist_fn = cast(
75
+ Callable[[TakopiSettings | None], list[str] | None],
76
+ _resolve_cli_attr("resolve_plugins_allowlist") or resolve_plugins_allowlist,
77
+ )
78
+ list_entrypoints_fn = cast(
79
+ Callable[..., list[EntryPoint]],
80
+ _resolve_cli_attr("list_entrypoints") or list_entrypoints,
81
+ )
82
+ get_backend_fn = cast(
83
+ Callable[..., object],
84
+ _resolve_cli_attr("get_backend") or get_backend,
85
+ )
86
+ get_transport_fn = cast(
87
+ Callable[..., object],
88
+ _resolve_cli_attr("get_transport") or get_transport,
89
+ )
90
+ get_command_fn = cast(
91
+ Callable[..., object],
92
+ _resolve_cli_attr("get_command") or get_command,
93
+ )
94
+ get_load_errors_fn = cast(
95
+ Callable[[], tuple[PluginLoadError, ...]],
96
+ _resolve_cli_attr("get_load_errors") or get_load_errors,
97
+ )
98
+ entrypoint_distribution_name_fn = cast(
99
+ Callable[[EntryPoint], str | None],
100
+ _resolve_cli_attr("entrypoint_distribution_name")
101
+ or entrypoint_distribution_name,
102
+ )
103
+ is_entrypoint_allowed_fn = cast(
104
+ Callable[[EntryPoint, set[str] | None], bool],
105
+ _resolve_cli_attr("is_entrypoint_allowed") or is_entrypoint_allowed,
106
+ )
107
+ normalize_allowlist_fn = cast(
108
+ Callable[[list[str] | None], set[str] | None],
109
+ _resolve_cli_attr("normalize_allowlist") or normalize_allowlist,
110
+ )
111
+
112
+ settings_hint, _ = load_settings_optional()
113
+ allowlist = resolve_plugins_allowlist_fn(settings_hint)
114
+
115
+ allowlist_set = normalize_allowlist_fn(allowlist)
116
+ engine_eps = list_entrypoints_fn(
117
+ ENGINE_GROUP,
118
+ reserved_ids=RESERVED_ENGINE_IDS,
119
+ )
120
+ transport_eps = list_entrypoints_fn(TRANSPORT_GROUP)
121
+ command_eps = list_entrypoints_fn(
122
+ COMMAND_GROUP,
123
+ reserved_ids=RESERVED_COMMAND_IDS,
124
+ )
125
+
126
+ _print_entrypoints(
127
+ "engine backends",
128
+ engine_eps,
129
+ allowlist=allowlist_set,
130
+ entrypoint_distribution_name_fn=entrypoint_distribution_name_fn,
131
+ is_entrypoint_allowed_fn=is_entrypoint_allowed_fn,
132
+ )
133
+ _print_entrypoints(
134
+ "transport backends",
135
+ transport_eps,
136
+ allowlist=allowlist_set,
137
+ entrypoint_distribution_name_fn=entrypoint_distribution_name_fn,
138
+ is_entrypoint_allowed_fn=is_entrypoint_allowed_fn,
139
+ )
140
+ _print_entrypoints(
141
+ "command backends",
142
+ command_eps,
143
+ allowlist=allowlist_set,
144
+ entrypoint_distribution_name_fn=entrypoint_distribution_name_fn,
145
+ is_entrypoint_allowed_fn=is_entrypoint_allowed_fn,
146
+ )
147
+
148
+ if load:
149
+ for ep in engine_eps:
150
+ if allowlist_set is not None and not is_entrypoint_allowed_fn(
151
+ ep, allowlist_set
152
+ ):
153
+ continue
154
+ try:
155
+ get_backend_fn(ep.name, allowlist=allowlist)
156
+ except ConfigError:
157
+ continue
158
+ for ep in transport_eps:
159
+ if allowlist_set is not None and not is_entrypoint_allowed_fn(
160
+ ep, allowlist_set
161
+ ):
162
+ continue
163
+ try:
164
+ get_transport_fn(ep.name, allowlist=allowlist)
165
+ except ConfigError:
166
+ continue
167
+ for ep in command_eps:
168
+ if allowlist_set is not None and not is_entrypoint_allowed_fn(
169
+ ep, allowlist_set
170
+ ):
171
+ continue
172
+ try:
173
+ get_command_fn(ep.name, allowlist=allowlist)
174
+ except ConfigError:
175
+ continue
176
+
177
+ errors = get_load_errors_fn()
178
+ if errors:
179
+ typer.echo("errors:")
180
+ for err in errors:
181
+ group = err.group
182
+ if group == ENGINE_GROUP:
183
+ group = "engine"
184
+ elif group == TRANSPORT_GROUP:
185
+ group = "transport"
186
+ elif group == COMMAND_GROUP:
187
+ group = "command"
188
+ dist = err.distribution or "unknown"
189
+ typer.echo(f" {group} {err.name} ({dist}): {err.error}")
190
+
191
+
192
+ def _resolve_cli_attr(name: str) -> object | None:
193
+ cli_module = sys.modules.get("takopi.cli")
194
+ if cli_module is None:
195
+ return None
196
+ return getattr(cli_module, name, None)