diff --git a/docs/developing.md b/docs/developing.md index 54ce59e..a3f1d72 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -77,12 +77,25 @@ Defines `Transport`, `MessageRef`, `RenderedMessage`, and `SendOptions`. Defines a renderer that converts `ProgressState` into `RenderedMessage` outputs. +### `transports.py` - Transport registry + +Defines the transport backend protocol, registry helpers, and built-in transport registration. + +### `config_migrations.py` - Config migrations + +Applies one-time edits to on-disk config (e.g., legacy Telegram key migration) before +`TakopiSettings` validation runs. + +### `telegram/backend.py` - Telegram transport backend + +Adapter that validates Telegram config, runs onboarding, and builds/runs the Telegram bridge. + ### `cli.py` - CLI entry point | Component | Purpose | |-----------|---------| | `run()` / `main()` | Typer CLI entry points | -| `_parse_bridge_config()` | Reads config + builds `TelegramBridgeConfig` + `ExecBridgeConfig` | +| `_run_auto_router()` | Loads settings, resolves transport + engine, builds router, delegates to transport backend | ### `progress.py` - Progress tracking @@ -261,6 +274,7 @@ Environment flags: - `PI_CODING_AGENT_DIR` (override Pi agent session directory base path) CLI flag: `--debug` enables debug logging (overrides `TAKOPI_LOG_LEVEL`). +CLI flag: `--transport ` overrides the configured transport backend. ### `telegram/onboarding.py` - Setup validation diff --git a/docs/projects.md b/docs/projects.md index 6517ff8..a94d982 100644 --- a/docs/projects.md +++ b/docs/projects.md @@ -20,7 +20,7 @@ All config lives in `~/.takopi/takopi.toml`. ```toml default_engine = "codex" # optional default_project = "z80" # optional -transport = "telegram" # required +transport = "telegram" # optional, defaults to "telegram" [transports.telegram] bot_token = "..." # required @@ -33,6 +33,9 @@ default_engine = "codex" # optional, per-project override worktree_base = "master" # optional, base for new branches ``` +Legacy config note: top-level `bot_token` / `chat_id` are auto-migrated into +`[transports.telegram]` on startup. + Note on `worktrees_dir`: - The default `.worktrees` lives inside the repo root. You'll see it as an @@ -49,6 +52,7 @@ Validation rules: - `default_project` must match a configured project alias. - Project aliases cannot collide with engine ids or reserved commands (`/cancel`). - `default_engine` and per-project `default_engine` must be valid engine ids. +- `transport` defaults to `"telegram"` when omitted; override per-run with `--transport`. ## `takopi init` diff --git a/readme.md b/readme.md index cd76a2b..2ff8159 100644 --- a/readme.md +++ b/readme.md @@ -53,6 +53,7 @@ global config `~/.takopi/takopi.toml` ```toml default_engine = "codex" +# optional, defaults to "telegram" transport = "telegram" [transports.telegram] @@ -81,7 +82,7 @@ provider = "openai" extra_args = ["--no-color"] ``` -note: configs with top-level `bot_token` / `chat_id` must be migrated to `[transports.telegram]`. +note: configs with top-level `bot_token` / `chat_id` are migrated to `[transports.telegram]` on startup. ## projects @@ -122,6 +123,13 @@ takopi opencode takopi pi ``` +list available transports (and override in a run): + +```sh +takopi transports +takopi --transport telegram +``` + resume lines always route to the matching engine; subcommands only override the default for new threads. send a message to the bot. diff --git a/src/takopi/cli.py b/src/takopi/cli.py index b0f13f0..b8e2daa 100644 --- a/src/takopi/cli.py +++ b/src/takopi/cli.py @@ -6,7 +6,6 @@ import sys from collections.abc import Callable from pathlib import Path -import anyio import typer from . import __version__ @@ -16,7 +15,6 @@ from .engines import get_backend, list_backends from .lockfile import LockError, LockHandle, acquire_lock, token_fingerprint from .logging import get_logger, setup_logging from .router import AutoRouter, RunnerEntry -from .runner_bridge import ExecBridgeConfig from .settings import ( TakopiSettings, load_settings, @@ -24,14 +22,7 @@ from .settings import ( require_telegram, validate_settings_data, ) -from .telegram.bridge import ( - TelegramBridgeConfig, - TelegramPresenter, - TelegramTransport, - run_main_loop, -) -from .telegram.client import TelegramClient -from .telegram.onboarding import SetupResult, check_setup, interactive_setup +from .transports import SetupResult, get_transport, list_transports from .utils.git import resolve_default_base, resolve_main_worktree_root logger = get_logger(__name__) @@ -47,6 +38,22 @@ def _version_callback(value: bool) -> None: _print_version_and_exit() +def _resolve_transport_id(override: str | None) -> str: + if override is not None: + value = override.strip() + if not value: + raise ConfigError("Invalid `--transport`; expected a non-empty string.") + return value + try: + config, _ = load_or_init_config() + except ConfigError: + return "telegram" + raw = config.get("transport") + if not isinstance(raw, str) or not raw.strip(): + return "telegram" + return raw.strip() + + def load_and_validate_config( path: str | Path | None = None, ) -> tuple[TakopiSettings, Path, str, int]: @@ -55,11 +62,12 @@ def load_and_validate_config( return settings, config_path, token, chat_id -def acquire_config_lock(config_path: Path, token: str) -> LockHandle: +def acquire_config_lock(config_path: Path, token: str | None) -> LockHandle: + fingerprint = token_fingerprint(token) if token else None try: return acquire_lock( config_path=config_path, - token_fingerprint=token_fingerprint(token), + token_fingerprint=fingerprint, ) except LockError as exc: lines = str(exc).splitlines() @@ -75,7 +83,10 @@ def acquire_config_lock(config_path: Path, token: str) -> LockHandle: def _default_engine_for_setup(override: str | None) -> str: if override: return override - loaded = load_settings_if_exists() + try: + loaded = load_settings_if_exists() + except ConfigError: + return "codex" if loaded is None: return "codex" settings, config_path = loaded @@ -173,72 +184,6 @@ def _build_router( return AutoRouter(entries=entries, default_engine=default_engine) -def _parse_bridge_config( - *, - final_notify: bool, - default_engine_override: str | None, - settings: TakopiSettings, - config_path: Path, - token: str, - chat_id: int, -) -> TelegramBridgeConfig: - startup_pwd = os.getcwd() - - backends = list_backends() - projects = settings.to_projects_config( - config_path=config_path, - engine_ids=[backend.id for backend in backends], - reserved=("cancel",), - ) - default_engine = _resolve_default_engine( - override=default_engine_override, - settings=settings, - config_path=config_path, - backends=backends, - ) - router = _build_router( - settings=settings, - config_path=config_path, - backends=backends, - default_engine=default_engine, - ) - available_engines = [entry.engine for entry in router.available_entries] - missing_engines = [entry.engine for entry in router.entries if not entry.available] - engine_list = ", ".join(available_engines) if available_engines else "none" - if missing_engines: - engine_list = f"{engine_list} (not installed: {', '.join(missing_engines)})" - project_aliases = sorted( - {project.alias for project in projects.projects.values()}, - key=str.lower, - ) - project_list = ", ".join(project_aliases) if project_aliases else "none" - startup_msg = ( - f"\N{OCTOPUS} **takopi is ready**\n\n" - f"default: `{router.default_engine}` \n" - f"agents: `{engine_list}` \n" - f"projects: `{project_list}` \n" - f"working in: `{startup_pwd}`" - ) - - bot = TelegramClient(token) - transport = TelegramTransport(bot) - presenter = TelegramPresenter() - exec_cfg = ExecBridgeConfig( - transport=transport, - presenter=presenter, - final_notify=final_notify, - ) - - return TelegramBridgeConfig( - bot=bot, - router=router, - chat_id=chat_id, - startup_msg=startup_msg, - exec_cfg=exec_cfg, - projects=projects, - ) - - def _config_path_display(path: Path) -> str: home = Path.home() try: @@ -259,12 +204,16 @@ def _setup_needs_config(setup: SetupResult) -> bool: def _fail_missing_config(path: Path) -> None: display = _config_path_display(path) - typer.echo(f"error: missing takopi config at {display}", err=True) + if path.exists(): + typer.echo(f"error: invalid takopi config at {display}", err=True) + else: + typer.echo(f"error: missing takopi config at {display}", err=True) def _run_auto_router( *, default_engine_override: str | None, + transport_override: str | None, final_notify: bool, debug: bool, onboard: bool, @@ -273,7 +222,9 @@ def _run_auto_router( lock_handle: LockHandle | None = None try: default_engine = _default_engine_for_setup(default_engine_override) - backend = get_backend(default_engine) + engine_backend = get_backend(default_engine) + transport_id = _resolve_transport_id(transport_override) + transport_backend = get_transport(transport_id) except ConfigError as e: typer.echo(f"error: {e}", err=True) raise typer.Exit(code=1) @@ -281,17 +232,37 @@ def _run_auto_router( if not _should_run_interactive(): typer.echo("error: --onboard requires a TTY", err=True) raise typer.Exit(code=1) - if not interactive_setup(force=True): + if not transport_backend.interactive_setup(force=True): raise typer.Exit(code=1) default_engine = _default_engine_for_setup(default_engine_override) - backend = get_backend(default_engine) - setup = check_setup(backend) + engine_backend = get_backend(default_engine) + setup = transport_backend.check_setup( + engine_backend, + transport_override=transport_override, + ) if not setup.ok: if _setup_needs_config(setup) and _should_run_interactive(): - if interactive_setup(force=False): + if setup.config_path.exists(): + display = _config_path_display(setup.config_path) + run_onboard = typer.confirm( + f"config at {display} is missing/invalid for " + f"{transport_backend.id}, run onboarding now?", + default=False, + ) + if run_onboard and transport_backend.interactive_setup(force=True): + default_engine = _default_engine_for_setup(default_engine_override) + engine_backend = get_backend(default_engine) + setup = transport_backend.check_setup( + engine_backend, + transport_override=transport_override, + ) + elif transport_backend.interactive_setup(force=False): default_engine = _default_engine_for_setup(default_engine_override) - backend = get_backend(default_engine) - setup = check_setup(backend) + engine_backend = get_backend(default_engine) + setup = transport_backend.check_setup( + engine_backend, + transport_override=transport_override, + ) if not setup.ok: if _setup_needs_config(setup): _fail_missing_config(setup.config_path) @@ -300,17 +271,40 @@ def _run_auto_router( typer.echo(f"error: {first.title}", err=True) raise typer.Exit(code=1) try: - settings, config_path, token, chat_id = load_and_validate_config() - lock_handle = acquire_config_lock(config_path, token) - cfg = _parse_bridge_config( + settings, config_path = load_settings() + if transport_override and transport_override != settings.transport: + settings = settings.model_copy(update={"transport": transport_override}) + backends = list_backends() + projects = settings.to_projects_config( + config_path=config_path, + engine_ids=[backend.id for backend in backends], + reserved=("cancel",), + ) + default_engine = _resolve_default_engine( + override=default_engine_override, + settings=settings, + config_path=config_path, + backends=backends, + ) + router = _build_router( + settings=settings, + config_path=config_path, + backends=backends, + default_engine=default_engine, + ) + lock_token = transport_backend.lock_token( + settings=settings, + config_path=config_path, + ) + lock_handle = acquire_config_lock(config_path, lock_token) + transport_backend.build_and_run( final_notify=final_notify, default_engine_override=default_engine_override, settings=settings, config_path=config_path, - token=token, - chat_id=chat_id, + router=router, + projects=projects, ) - anyio.run(run_main_loop, cfg) except ConfigError as e: typer.echo(f"error: {e}", err=True) raise typer.Exit(code=1) @@ -423,6 +417,13 @@ def init( typer.echo(f"saved project {alias!r} to {_config_path_display(config_path)}") +def transports_cmd() -> None: + """List available transport backends.""" + ids = list_transports() + for transport_id in ids: + typer.echo(transport_id) + + app = typer.Typer( add_completion=False, invoke_without_command=True, @@ -431,6 +432,7 @@ app = typer.Typer( app.command(name="init")(init) +app.command(name="transports")(transports_cmd) @app.callback() @@ -453,6 +455,11 @@ def app_main( "--onboard/--no-onboard", help="Run the interactive setup wizard before starting.", ), + transport: str | None = typer.Option( + None, + "--transport", + help="Override the transport backend id.", + ), debug: bool = typer.Option( False, "--debug/--no-debug", @@ -463,6 +470,7 @@ def app_main( if ctx.invoked_subcommand is None: _run_auto_router( default_engine_override=None, + transport_override=transport, final_notify=final_notify, debug=debug, onboard=onboard, @@ -482,6 +490,11 @@ def make_engine_cmd(engine_id: str) -> Callable[..., None]: "--onboard/--no-onboard", help="Run the interactive setup wizard before starting.", ), + transport: str | None = typer.Option( + None, + "--transport", + help="Override the transport backend id.", + ), debug: bool = typer.Option( False, "--debug/--no-debug", @@ -490,6 +503,7 @@ def make_engine_cmd(engine_id: str) -> Callable[..., None]: ) -> None: _run_auto_router( default_engine_override=engine_id, + transport_override=transport, final_notify=final_notify, debug=debug, onboard=onboard, diff --git a/src/takopi/config_migrations.py b/src/takopi/config_migrations.py new file mode 100644 index 0000000..77a5a33 --- /dev/null +++ b/src/takopi/config_migrations.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from .config import ConfigError +from .config_store import read_raw_toml, write_raw_toml +from .logging import get_logger + +logger = get_logger(__name__) + + +def _ensure_table( + config: dict[str, Any], + key: str, + *, + config_path: Path, + label: str | None = None, +) -> dict[str, Any]: + value = config.get(key) + if value is None: + table: dict[str, Any] = {} + config[key] = table + return table + if not isinstance(value, dict): + name = label or key + raise ConfigError(f"Invalid `{name}` in {config_path}; expected a table.") + return value + + +def _migrate_legacy_telegram(config: dict[str, Any], *, config_path: Path) -> bool: + has_legacy = "bot_token" in config or "chat_id" in config + if not has_legacy: + return False + + transports = _ensure_table(config, "transports", config_path=config_path) + telegram = transports.get("telegram") + if telegram is None: + telegram = {} + transports["telegram"] = telegram + if not isinstance(telegram, dict): + raise ConfigError( + f"Invalid `transports.telegram` in {config_path}; expected a table." + ) + + if "bot_token" in config and "bot_token" not in telegram: + telegram["bot_token"] = config["bot_token"] + if "chat_id" in config and "chat_id" not in telegram: + telegram["chat_id"] = config["chat_id"] + + config.pop("bot_token", None) + config.pop("chat_id", None) + config.setdefault("transport", "telegram") + return True + + +def migrate_config(config: dict[str, Any], *, config_path: Path) -> list[str]: + applied: list[str] = [] + if _migrate_legacy_telegram(config, config_path=config_path): + applied.append("legacy-telegram") + return applied + + +def migrate_config_file(path: Path) -> list[str]: + config = read_raw_toml(path) + applied = migrate_config(config, config_path=path) + if applied: + write_raw_toml(config, path) + for migration in applied: + logger.info( + "config.migrated", + migration=migration, + path=str(path), + ) + return applied diff --git a/src/takopi/settings.py b/src/takopi/settings.py index e86e667..8607804 100644 --- a/src/takopi/settings.py +++ b/src/takopi/settings.py @@ -17,6 +17,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings.sources import TomlConfigSettingsSource from .config import ConfigError, ProjectConfig, ProjectsConfig, HOME_CONFIG_PATH +from .config_migrations import migrate_config_file class TelegramTransportSettings(BaseModel): @@ -263,6 +264,7 @@ class TakopiSettings(BaseSettings): def load_settings(path: str | Path | None = None) -> tuple[TakopiSettings, Path]: cfg_path = _resolve_config_path(path) _ensure_config_file(cfg_path) + migrate_config_file(cfg_path) return _load_settings_from_path(cfg_path), cfg_path @@ -275,6 +277,7 @@ def load_settings_if_exists( raise ConfigError( f"Config path {cfg_path} exists but is not a file." ) from None + migrate_config_file(cfg_path) return _load_settings_from_path(cfg_path), cfg_path return None diff --git a/src/takopi/telegram/backend.py b/src/takopi/telegram/backend.py new file mode 100644 index 0000000..e97de6f --- /dev/null +++ b/src/takopi/telegram/backend.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import os +from pathlib import Path + +import anyio + +from ..backends import EngineBackend +from ..config import ProjectsConfig +from ..router import AutoRouter +from ..runner_bridge import ExecBridgeConfig +from ..settings import TakopiSettings, require_telegram +from ..transports import SetupResult, TransportBackend +from .bridge import ( + TelegramBridgeConfig, + TelegramPresenter, + TelegramTransport, + run_main_loop, +) +from .client import TelegramClient +from .onboarding import check_setup, interactive_setup + + +def _build_startup_message( + router: AutoRouter, + projects: ProjectsConfig, + *, + startup_pwd: str, +) -> str: + available_engines = [entry.engine for entry in router.available_entries] + missing_engines = [entry.engine for entry in router.entries if not entry.available] + engine_list = ", ".join(available_engines) if available_engines else "none" + if missing_engines: + engine_list = f"{engine_list} (not installed: {', '.join(missing_engines)})" + project_aliases = sorted( + {project.alias for project in projects.projects.values()}, + key=str.lower, + ) + project_list = ", ".join(project_aliases) if project_aliases else "none" + return ( + f"\N{OCTOPUS} **takopi is ready**\n\n" + f"default: `{router.default_engine}` \n" + f"agents: `{engine_list}` \n" + f"projects: `{project_list}` \n" + f"working in: `{startup_pwd}`" + ) + + +class TelegramBackend(TransportBackend): + id = "telegram" + description = "Telegram bot" + + def check_setup( + self, + engine_backend: EngineBackend, + *, + transport_override: str | None = None, + ) -> SetupResult: + return check_setup(engine_backend, transport_override=transport_override) + + def interactive_setup(self, *, force: bool) -> bool: + return interactive_setup(force=force) + + def lock_token(self, *, settings: TakopiSettings, config_path: Path) -> str | None: + token, _ = require_telegram(settings, config_path) + return token + + def build_and_run( + self, + *, + settings: TakopiSettings, + config_path: Path, + router: AutoRouter, + projects: ProjectsConfig, + final_notify: bool, + default_engine_override: str | None, + ) -> None: + token, chat_id = require_telegram(settings, config_path) + startup_msg = _build_startup_message( + router, + projects, + startup_pwd=os.getcwd(), + ) + bot = TelegramClient(token) + transport = TelegramTransport(bot) + presenter = TelegramPresenter() + exec_cfg = ExecBridgeConfig( + transport=transport, + presenter=presenter, + final_notify=final_notify, + ) + cfg = TelegramBridgeConfig( + bot=bot, + router=router, + chat_id=chat_id, + startup_msg=startup_msg, + exec_cfg=exec_cfg, + projects=projects, + ) + anyio.run(run_main_loop, cfg) + + +telegram_backend = TelegramBackend() diff --git a/src/takopi/telegram/onboarding.py b/src/takopi/telegram/onboarding.py index 6ed7774..2a55f74 100644 --- a/src/takopi/telegram/onboarding.py +++ b/src/takopi/telegram/onboarding.py @@ -27,19 +27,10 @@ from ..config_store import read_raw_toml, write_raw_toml from ..engines import list_backends from ..logging import suppress_logs from ..settings import HOME_CONFIG_PATH, load_settings, require_telegram +from ..transports import SetupResult from .client import TelegramClient, TelegramRetryAfter -@dataclass(slots=True) -class SetupResult: - issues: list[SetupIssue] - config_path: Path = HOME_CONFIG_PATH - - @property - def ok(self) -> bool: - return not self.issues - - @dataclass(frozen=True, slots=True) class ChatInfo: chat_id: int @@ -81,7 +72,11 @@ def config_issue(path: Path) -> SetupIssue: return SetupIssue("create a config", (f" {_display_path(path)}",)) -def check_setup(backend: EngineBackend) -> SetupResult: +def check_setup( + backend: EngineBackend, + *, + transport_override: str | None = None, +) -> SetupResult: issues: list[SetupIssue] = [] config_path = HOME_CONFIG_PATH cmd = backend.cli_cmd or backend.id @@ -91,6 +86,8 @@ def check_setup(backend: EngineBackend) -> SetupResult: try: settings, config_path = load_settings() + if transport_override: + settings = settings.model_copy(update={"transport": transport_override}) try: require_telegram(settings, config_path) except ConfigError: diff --git a/src/takopi/transports.py b/src/takopi/transports.py new file mode 100644 index 0000000..e05f2ad --- /dev/null +++ b/src/takopi/transports.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol + +from .backends import EngineBackend, SetupIssue +from .config import ConfigError, ProjectsConfig +from .router import AutoRouter +from .settings import TakopiSettings + + +@dataclass(frozen=True, slots=True) +class SetupResult: + issues: list[SetupIssue] + config_path: Path + + @property + def ok(self) -> bool: + return not self.issues + + +class TransportBackend(Protocol): + id: str + description: str + + def check_setup( + self, + engine_backend: EngineBackend, + *, + transport_override: str | None = None, + ) -> SetupResult: ... + + def interactive_setup(self, *, force: bool) -> bool: ... + + def lock_token( + self, *, settings: TakopiSettings, config_path: Path + ) -> str | None: ... + + def build_and_run( + self, + *, + settings: TakopiSettings, + config_path: Path, + router: AutoRouter, + projects: ProjectsConfig, + final_notify: bool, + default_engine_override: str | None, + ) -> None: ... + + +_registry: dict[str, TransportBackend] = {} +_builtins_loaded = False + + +def register_transport(backend: TransportBackend) -> None: + existing = _registry.get(backend.id) + if existing is not None and existing is not backend: + raise ConfigError(f"Transport {backend.id!r} is already registered.") + _registry[backend.id] = backend + + +def register_builtin_transports() -> None: + global _builtins_loaded + if _builtins_loaded: + return + from .telegram.backend import telegram_backend + + register_transport(telegram_backend) + _builtins_loaded = True + + +def get_transport(transport_id: str) -> TransportBackend: + register_builtin_transports() + try: + return _registry[transport_id] + except KeyError: + available = ", ".join(sorted(_registry)) + raise ConfigError( + f"Unknown transport {transport_id!r}. Available: {available}." + ) from None + + +def list_transports() -> list[str]: + register_builtin_transports() + return sorted(_registry) diff --git a/tests/test_settings.py b/tests/test_settings.py index 8648399..8508faf 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -5,6 +5,7 @@ from pathlib import Path import pytest from takopi.config import ConfigError +from takopi.config_store import read_raw_toml from takopi.settings import ( TakopiSettings, load_settings, @@ -58,12 +59,20 @@ def test_env_overrides_toml(tmp_path: Path, monkeypatch) -> None: assert settings.default_engine == "claude" -def test_legacy_keys_rejected(tmp_path: Path) -> None: +def test_legacy_keys_migrated(tmp_path: Path) -> None: config_path = tmp_path / "takopi.toml" config_path.write_text('bot_token = "token"\nchat_id = 123\n', encoding="utf-8") - with pytest.raises(ConfigError, match="transports\\.telegram"): - load_settings(config_path) + settings, loaded_path = load_settings(config_path) + + assert loaded_path == config_path + assert settings.transports.telegram.chat_id == 123 + raw = read_raw_toml(config_path) + assert "bot_token" not in raw + assert "chat_id" not in raw + assert raw["transports"]["telegram"]["bot_token"] == "token" + assert raw["transports"]["telegram"]["chat_id"] == 123 + assert raw["transport"] == "telegram" def test_validate_settings_data_rejects_invalid_bot_token_type(tmp_path: Path) -> None: diff --git a/tests/test_transport_registry.py b/tests/test_transport_registry.py new file mode 100644 index 0000000..286a75c --- /dev/null +++ b/tests/test_transport_registry.py @@ -0,0 +1,19 @@ +import pytest + +from takopi import transports +from takopi.config import ConfigError + + +def test_transport_registry_lists_telegram() -> None: + ids = transports.list_transports() + assert "telegram" in ids + + +def test_transport_registry_gets_telegram() -> None: + backend = transports.get_transport("telegram") + assert backend.id == "telegram" + + +def test_transport_registry_unknown() -> None: + with pytest.raises(ConfigError, match="Unknown transport"): + transports.get_transport("nope")