mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 04:35:58 -10:00
fix: non-interactive per-channel gateway config lifecycle (#6082)
* fix: add non-interactive per-channel gateway config lifecycle (fixes #6079) Adds 'praisonai gateway channels add|update|disable|remove' and an importable edit_gateway_channel() helper. Each edit is a surgical read-modify-write of channels:, validated against GatewayConfigSchema before an atomic write, so invalid mutations are never persisted. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix: honour parent --config, legacy platform formats, env-resolved validation and last-channel guard in gateway channel edits Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix: lock gateway channel edits and resolve ${VAR} platform before legacy conversion Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: MervinPraison <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
4 files changed
+431
-6
No files matched your search
@@ -14,6 +14,7 @@ _LAZY_EXPORTS = {
|
||||
"praisonai_bot.gateway.admin",
|
||||
"provision_gateway_config",
|
||||
),
|
||||
"edit_gateway_channel": ("praisonai_bot.gateway.admin", "edit_gateway_channel"),
|
||||
}
|
||||
|
||||
|
||||
@@ -34,5 +35,6 @@ __all__ = [
|
||||
"GatewayRepairResult",
|
||||
"repair_gateway_config",
|
||||
"provision_gateway_config",
|
||||
"edit_gateway_channel",
|
||||
"__version__",
|
||||
]
|
||||
@@ -1553,8 +1553,16 @@ def gateway_test(
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command("channels")
|
||||
channels_app = typer.Typer(
|
||||
help="List, probe and edit channels in gateway.yaml",
|
||||
invoke_without_command=True,
|
||||
)
|
||||
app.add_typer(channels_app, name="channels")
|
||||
|
||||
|
||||
@channels_app.callback()
|
||||
def gateway_channels(
|
||||
ctx: typer.Context,
|
||||
config: str = typer.Option("gateway.yaml", "--config", "-c", help="Path to gateway.yaml"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
probe: bool = typer.Option(False, "--probe", help="Probe each channel's credentials"),
|
||||
@@ -1574,6 +1582,9 @@ def gateway_channels(
|
||||
import os
|
||||
import yaml
|
||||
|
||||
if ctx.invoked_subcommand is not None:
|
||||
return
|
||||
|
||||
if available:
|
||||
try:
|
||||
from praisonai_bot.bots._registry import list_platforms
|
||||
@@ -1662,6 +1673,125 @@ def gateway_channels(
|
||||
print(f"{name:<20} {platform:<12} {has_token:<12}")
|
||||
|
||||
|
||||
def _parse_channel_settings(pairs: Optional[List[str]]) -> dict:
|
||||
"""Parse repeated ``--set key=value`` flags; values are YAML-typed."""
|
||||
import yaml
|
||||
|
||||
settings = {}
|
||||
for pair in pairs or []:
|
||||
key, sep, value = pair.partition("=")
|
||||
if not sep or not key.strip():
|
||||
print(f"Error: --set expects key=value, got {pair!r}")
|
||||
raise typer.Exit(1)
|
||||
settings[key.strip()] = yaml.safe_load(value) if value else ""
|
||||
return settings
|
||||
|
||||
|
||||
def _edit_channel(
|
||||
ctx: typer.Context, config: Optional[str], name: str, settings=None, *, remove=False, must_exist=True
|
||||
) -> None:
|
||||
"""Run one validated channel edit, mapping failures to CLI exits (#6079)."""
|
||||
import os
|
||||
import yaml
|
||||
|
||||
from praisonai_bot.gateway.admin import _editable_channels, edit_gateway_channel
|
||||
|
||||
# ``gateway channels --config X <cmd>`` applies to the subcommand too.
|
||||
config = _resolve_doctor_config(config or ctx.parent.params.get("config") or "gateway.yaml")
|
||||
if not os.path.exists(config):
|
||||
print(f"Error: Config file not found: {config}")
|
||||
raise typer.Exit(1)
|
||||
with open(config) as fh:
|
||||
exists = name in _editable_channels(yaml.safe_load(fh) or {})
|
||||
if must_exist and not exists:
|
||||
print(f"Error: channel {name!r} is not configured in {config}")
|
||||
raise typer.Exit(1)
|
||||
if not must_exist and exists:
|
||||
print(f"Error: channel {name!r} already exists in {config}; use 'update'")
|
||||
raise typer.Exit(1)
|
||||
try:
|
||||
edit_gateway_channel(config, name, settings, remove=remove)
|
||||
except ValueError as exc:
|
||||
print(f"Error: {exc}\nNo changes written to {config}.")
|
||||
raise typer.Exit(1) from exc
|
||||
|
||||
|
||||
@channels_app.command("add")
|
||||
def gateway_channels_add(
|
||||
ctx: typer.Context,
|
||||
name: str = typer.Argument(..., help="Channel name, e.g. 'telegram'"),
|
||||
platform: Optional[str] = typer.Option(None, "--platform", help="Platform (default: the channel name)"),
|
||||
token: Optional[str] = typer.Option(
|
||||
None, "--token",
|
||||
help="Channel token (default: ${VAR} reference to the platform's credential env var)",
|
||||
),
|
||||
set_: Optional[List[str]] = typer.Option(None, "--set", help="Extra setting key=value (repeatable)"),
|
||||
config: Optional[str] = typer.Option(None, "--config", "-c", help="Path to gateway.yaml"),
|
||||
):
|
||||
"""Add one channel to gateway.yaml without touching the others.
|
||||
|
||||
Examples:
|
||||
praisonai gateway channels add telegram
|
||||
praisonai gateway channels add support --platform discord --set group_policy=mention_only
|
||||
"""
|
||||
from praisonai_bot.bots._config_schema import GatewayConfigSchema
|
||||
|
||||
platform = platform or name
|
||||
settings = {"platform": platform}
|
||||
settings["token"] = token if token is not None else GatewayConfigSchema._credential_env_ref(platform)
|
||||
settings.update(_parse_channel_settings(set_))
|
||||
_edit_channel(ctx, config, name, settings, must_exist=False)
|
||||
print(f"Added channel {name!r}.")
|
||||
|
||||
|
||||
@channels_app.command("update")
|
||||
def gateway_channels_update(
|
||||
ctx: typer.Context,
|
||||
name: str = typer.Argument(..., help="Channel name to update"),
|
||||
token: Optional[str] = typer.Option(None, "--token", help="New channel token"),
|
||||
set_: Optional[List[str]] = typer.Option(None, "--set", help="Setting key=value (repeatable)"),
|
||||
config: Optional[str] = typer.Option(None, "--config", "-c", help="Path to gateway.yaml"),
|
||||
):
|
||||
"""Update settings of one configured channel.
|
||||
|
||||
Examples:
|
||||
praisonai gateway channels update telegram --set group_policy=mention_only
|
||||
"""
|
||||
settings = _parse_channel_settings(set_)
|
||||
if token is not None:
|
||||
settings["token"] = token
|
||||
if not settings:
|
||||
print("Error: nothing to update; pass --token and/or --set key=value")
|
||||
raise typer.Exit(1)
|
||||
_edit_channel(ctx, config, name, settings)
|
||||
print(f"Updated channel {name!r}.")
|
||||
|
||||
|
||||
@channels_app.command("disable")
|
||||
def gateway_channels_disable(
|
||||
ctx: typer.Context,
|
||||
name: str = typer.Argument(..., help="Channel name to disable"),
|
||||
config: Optional[str] = typer.Option(None, "--config", "-c", help="Path to gateway.yaml"),
|
||||
):
|
||||
"""Stop serving a channel but keep its config (sets ``enabled: false``).
|
||||
|
||||
Re-enable with ``praisonai gateway channels update <name> --set enabled=true``.
|
||||
"""
|
||||
_edit_channel(ctx, config, name, {"enabled": False})
|
||||
print(f"Disabled channel {name!r}.")
|
||||
|
||||
|
||||
@channels_app.command("remove")
|
||||
def gateway_channels_remove(
|
||||
ctx: typer.Context,
|
||||
name: str = typer.Argument(..., help="Channel name to remove"),
|
||||
config: Optional[str] = typer.Option(None, "--config", "-c", help="Path to gateway.yaml"),
|
||||
):
|
||||
"""Delete a channel block from gateway.yaml."""
|
||||
_edit_channel(ctx, config, name, remove=True)
|
||||
print(f"Removed channel {name!r}.")
|
||||
|
||||
|
||||
def _resolve_gateway_rest_url(
|
||||
url: Optional[str],
|
||||
host: Optional[str] = None,
|
||||
|
||||
@@ -15,6 +15,7 @@ one canonical flow (DRY).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
@@ -201,7 +202,6 @@ def _repair_config_version(config_path: str):
|
||||
an interruption, full disk, or I/O error during ``doctor --fix`` can never
|
||||
leave the config truncated or half-written.
|
||||
"""
|
||||
import tempfile
|
||||
import yaml
|
||||
|
||||
with open(config_path) as fh:
|
||||
@@ -210,6 +210,14 @@ def _repair_config_version(config_path: str):
|
||||
from praisonaiagents.gateway.config import migrate_config_with_doctor
|
||||
|
||||
migrated, reasons = migrate_config_with_doctor(cfg)
|
||||
_atomic_write_yaml(config_path, migrated)
|
||||
return reasons
|
||||
|
||||
|
||||
def _atomic_write_yaml(config_path: str, data: dict) -> None:
|
||||
"""Serialise ``data`` to a temp file and ``os.replace`` it over the config."""
|
||||
import tempfile
|
||||
import yaml
|
||||
|
||||
directory = os.path.dirname(os.path.abspath(config_path))
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
@@ -217,9 +225,7 @@ def _repair_config_version(config_path: str):
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w") as fh:
|
||||
yaml.safe_dump(
|
||||
migrated, fh, default_flow_style=False, sort_keys=False
|
||||
)
|
||||
yaml.safe_dump(data, fh, default_flow_style=False, sort_keys=False)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp_path, config_path)
|
||||
@@ -230,7 +236,127 @@ def _repair_config_version(config_path: str):
|
||||
pass
|
||||
raise
|
||||
|
||||
return reasons
|
||||
|
||||
def edit_gateway_channel(
|
||||
config_path: PathLike,
|
||||
name: str,
|
||||
settings: Optional[Dict] = None,
|
||||
*,
|
||||
remove: bool = False,
|
||||
) -> dict:
|
||||
"""Add, update or remove one ``channels:`` entry in place (Issue #6079).
|
||||
|
||||
A surgical read-modify-write: ``settings`` is merged into
|
||||
``channels[name]`` (created if absent, ``platform`` defaulting to ``name``)
|
||||
or, with ``remove=True``, the block is deleted. Other channels and
|
||||
top-level keys are untouched. The edited document is validated against
|
||||
``GatewayConfigSchema`` before the atomic write, so an invalid mutation is
|
||||
rejected rather than persisted. Disable a channel with
|
||||
``settings={"enabled": False}``.
|
||||
|
||||
Returns:
|
||||
The resulting channel block (``{}`` when removed).
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If ``config_path`` does not exist.
|
||||
KeyError: If ``remove=True`` and the channel is not configured.
|
||||
ValueError: If the edited config fails validation.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
config_path = os.fspath(config_path)
|
||||
if not os.path.isfile(config_path):
|
||||
raise FileNotFoundError(config_path)
|
||||
# Hold a sidecar lock across read-modify-write so concurrent edits of
|
||||
# different channels cannot overwrite each other.
|
||||
with _config_edit_lock(config_path):
|
||||
with open(config_path) as fh:
|
||||
cfg = yaml.safe_load(fh) or {}
|
||||
return _apply_channel_edit(config_path, cfg, name, settings, remove)
|
||||
|
||||
|
||||
def _apply_channel_edit(config_path, cfg, name, settings, remove) -> dict:
|
||||
from praisonai_bot.bots._config_schema import validate_gateway_config
|
||||
|
||||
channels = _editable_channels(cfg)
|
||||
if remove:
|
||||
if name not in channels:
|
||||
raise KeyError(f"channel {name!r} is not configured in {config_path}")
|
||||
channels.pop(name)
|
||||
block: dict = {}
|
||||
else:
|
||||
block = dict(channels.get(name) or {})
|
||||
block.setdefault("platform", name)
|
||||
block.update(settings or {})
|
||||
channels[name] = block
|
||||
cfg["channels"] = channels
|
||||
|
||||
# Check the document itself: the validator may otherwise satisfy its
|
||||
# "at least one channel" rule from env credentials, which are not saved.
|
||||
if not any(
|
||||
not isinstance(b, dict) or b.get("enabled", True) is not False
|
||||
for b in channels.values()
|
||||
):
|
||||
raise ValueError("at least one enabled channel must remain configured")
|
||||
# Validate an env-resolved copy (as the runtime does; unset ``${VAR}``
|
||||
# refs are left as-is) while the saved file keeps the references.
|
||||
validate_gateway_config(cfg)
|
||||
_atomic_write_yaml(config_path, cfg)
|
||||
return block
|
||||
|
||||
|
||||
def _editable_channels(cfg: dict) -> dict:
|
||||
"""Return ``cfg``'s ``channels`` map, moving legacy declarations into it.
|
||||
|
||||
Single-bot (top-level ``platform``) and BotOS (``platforms``) files are
|
||||
only honoured by the loader while ``channels`` is empty, so they are
|
||||
converted in place before an edit adds a ``channels:`` block. ``${VAR}``
|
||||
references are copied verbatim.
|
||||
"""
|
||||
channels = cfg.get("channels") or {}
|
||||
if channels:
|
||||
return channels
|
||||
platform = cfg.get("platform")
|
||||
if platform:
|
||||
# Mirrors the loader's single-bot migration, which wins over BotOS.
|
||||
from praisonai_bot.bots._config_schema import GatewayConfigSchema
|
||||
|
||||
# Resolve a ``${VAR}`` platform first, as the loader does.
|
||||
raw = str(cfg.pop("platform"))
|
||||
platform = str(_resolve_env_token(raw) or raw).lower().strip()
|
||||
block = {"platform": platform}
|
||||
token = cfg.pop("token", None)
|
||||
block["token"] = token or GatewayConfigSchema._credential_env_ref(platform)
|
||||
if platform == "slack" and cfg.get("app_token") is not None:
|
||||
block["app_token"] = cfg.pop("app_token")
|
||||
for key in ("unknown_user_policy", "owner_user_id"):
|
||||
if cfg.get(key) is not None:
|
||||
block[key] = cfg[key]
|
||||
channels[platform] = block
|
||||
elif cfg.get("platforms"):
|
||||
for pname, pcfg in cfg.pop("platforms").items():
|
||||
channels[pname] = {"platform": pname, **(pcfg or {})}
|
||||
return channels
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _config_edit_lock(config_path: str):
|
||||
"""Exclusive OS advisory lock on ``<config>.lock`` (no-op if unsupported)."""
|
||||
with open(config_path + ".lock", "a+") as fh:
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError: # pragma: no cover - Windows
|
||||
fcntl = None
|
||||
if fcntl is not None:
|
||||
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
|
||||
else: # pragma: no cover - Windows
|
||||
try:
|
||||
import msvcrt
|
||||
|
||||
msvcrt.locking(fh.fileno(), msvcrt.LK_LOCK, 1)
|
||||
except Exception:
|
||||
pass
|
||||
yield # released when ``fh`` closes
|
||||
|
||||
|
||||
def _repair_gateway_secret(dry_run: bool = False, config_path: str = ""):
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Per-channel config lifecycle: add / update / disable / remove (Issue #6079)."""
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from praisonai_bot.cli.commands.gateway import app
|
||||
from praisonai_bot.gateway.admin import edit_gateway_channel
|
||||
|
||||
BASE = {
|
||||
"agents": {"assistant": {"instructions": "hi"}},
|
||||
"channels": {
|
||||
"telegram": {"platform": "telegram", "token": "${TELEGRAM_BOT_TOKEN}"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config(tmp_path):
|
||||
path = tmp_path / "gateway.yaml"
|
||||
path.write_text(yaml.safe_dump(BASE, sort_keys=False))
|
||||
return path
|
||||
|
||||
|
||||
def _load(path):
|
||||
return yaml.safe_load(path.read_text())
|
||||
|
||||
|
||||
def _run(*args):
|
||||
return CliRunner().invoke(app, ["channels", *args])
|
||||
|
||||
|
||||
def test_add_channel_keeps_others_and_defaults_env_token(config):
|
||||
result = _run("add", "discord", "--config", str(config), "--set", "group_policy=mention_only")
|
||||
assert result.exit_code == 0, result.output
|
||||
channels = _load(config)["channels"]
|
||||
assert channels["telegram"] == BASE["channels"]["telegram"]
|
||||
assert channels["discord"] == {
|
||||
"platform": "discord",
|
||||
"token": "${DISCORD_BOT_TOKEN}",
|
||||
"group_policy": "mention_only",
|
||||
}
|
||||
assert _load(config)["agents"] == BASE["agents"]
|
||||
|
||||
|
||||
def test_add_existing_channel_is_rejected(config):
|
||||
result = _run("add", "telegram", "--config", str(config))
|
||||
assert result.exit_code == 1
|
||||
assert _load(config) == BASE
|
||||
|
||||
|
||||
def test_update_and_disable_channel(config):
|
||||
assert _run("update", "telegram", "--config", str(config), "--token", "${NEW_TOKEN}").exit_code == 0
|
||||
assert _run("add", "discord", "--config", str(config)).exit_code == 0
|
||||
assert _run("disable", "telegram", "--config", str(config)).exit_code == 0
|
||||
telegram = _load(config)["channels"]["telegram"]
|
||||
assert telegram["token"] == "${NEW_TOKEN}"
|
||||
assert telegram["enabled"] is False
|
||||
|
||||
|
||||
def test_invalid_update_is_not_persisted(config):
|
||||
result = _run("update", "telegram", "--config", str(config), "--set", "allowed_users=[1, 2]")
|
||||
assert result.exit_code == 1
|
||||
assert _load(config) == BASE
|
||||
|
||||
|
||||
def test_unknown_platform_is_rejected(config):
|
||||
assert _run("add", "bogus", "--config", str(config)).exit_code == 1
|
||||
assert _load(config) == BASE
|
||||
|
||||
|
||||
def test_remove_channel(config):
|
||||
assert _run("add", "discord", "--config", str(config)).exit_code == 0
|
||||
assert _run("remove", "discord", "--config", str(config)).exit_code == 0
|
||||
assert "discord" not in _load(config)["channels"]
|
||||
assert _run("remove", "discord", "--config", str(config)).exit_code == 1
|
||||
|
||||
|
||||
def test_python_helper_matches_cli(config):
|
||||
block = edit_gateway_channel(config, "discord", {"token": "${D}"})
|
||||
assert block == {"platform": "discord", "token": "${D}"}
|
||||
with pytest.raises(KeyError):
|
||||
edit_gateway_channel(config, "nope", remove=True)
|
||||
with pytest.raises(ValueError):
|
||||
edit_gateway_channel(config, "bogus", {})
|
||||
assert "bogus" not in _load(config)["channels"]
|
||||
|
||||
|
||||
def test_list_still_works_without_subcommand(config):
|
||||
result = _run("--config", str(config), "--json")
|
||||
assert result.exit_code == 0
|
||||
assert '"telegram"' in result.output
|
||||
|
||||
|
||||
def test_parent_config_option_applies_to_subcommand(config, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
other = tmp_path / "other.yaml"
|
||||
other.write_text(yaml.safe_dump(BASE, sort_keys=False))
|
||||
result = _run("--config", str(other), "update", "telegram", "--set", "group_policy=mention_only")
|
||||
assert result.exit_code == 0, result.output
|
||||
assert _load(other)["channels"]["telegram"]["group_policy"] == "mention_only"
|
||||
assert _load(config) == BASE
|
||||
|
||||
|
||||
def test_single_bot_platform_is_converted_not_hidden(tmp_path):
|
||||
path = tmp_path / "bot.yaml"
|
||||
path.write_text(yaml.safe_dump({"agent": {"instructions": "hi"}, "platform": "telegram", "token": "${T}"}))
|
||||
edit_gateway_channel(path, "discord", {"token": "${D}"})
|
||||
cfg = _load(path)
|
||||
assert "platform" not in cfg and "token" not in cfg
|
||||
assert cfg["channels"]["telegram"] == {"platform": "telegram", "token": "${T}"}
|
||||
assert cfg["channels"]["discord"]["token"] == "${D}"
|
||||
|
||||
|
||||
def test_botos_platforms_are_converted(tmp_path):
|
||||
path = tmp_path / "bot.yaml"
|
||||
path.write_text(yaml.safe_dump({"agent": {"instructions": "hi"}, "platforms": {"telegram": {"token": "${T}"}}}))
|
||||
result = CliRunner().invoke(app, ["channels", "update", "telegram", "--config", str(path), "--set", "group_policy=mention_only"])
|
||||
assert result.exit_code == 0, result.output
|
||||
cfg = _load(path)
|
||||
assert "platforms" not in cfg
|
||||
assert cfg["channels"]["telegram"] == {"platform": "telegram", "token": "${T}", "group_policy": "mention_only"}
|
||||
|
||||
|
||||
def test_env_placeholder_in_typed_field_does_not_block_edit(config, monkeypatch):
|
||||
cfg = _load(config)
|
||||
cfg["channels"]["telegram"]["max_inbound_media_bytes"] = "${MEDIA_LIMIT}"
|
||||
config.write_text(yaml.safe_dump(cfg, sort_keys=False))
|
||||
monkeypatch.setenv("MEDIA_LIMIT", "1024")
|
||||
edit_gateway_channel(config, "telegram", {"token": "${NEW}"})
|
||||
saved = _load(config)["channels"]["telegram"]
|
||||
assert saved["max_inbound_media_bytes"] == "${MEDIA_LIMIT}"
|
||||
assert saved["token"] == "${NEW}"
|
||||
|
||||
|
||||
def test_removing_last_channel_is_rejected_even_with_env_token(config, monkeypatch):
|
||||
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "x")
|
||||
with pytest.raises(ValueError):
|
||||
edit_gateway_channel(config, "telegram", remove=True)
|
||||
with pytest.raises(ValueError):
|
||||
edit_gateway_channel(config, "telegram", {"enabled": False})
|
||||
assert _load(config) == BASE
|
||||
|
||||
|
||||
def test_env_platform_reference_is_resolved_before_conversion(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("BOT_PLATFORM", "telegram")
|
||||
path = tmp_path / "bot.yaml"
|
||||
path.write_text(yaml.safe_dump({"agent": {"instructions": "hi"}, "platform": "${BOT_PLATFORM}", "token": "${T}"}))
|
||||
edit_gateway_channel(path, "discord", {"token": "${D}"})
|
||||
cfg = _load(path)
|
||||
assert cfg["channels"]["telegram"] == {"platform": "telegram", "token": "${T}"}
|
||||
assert cfg["channels"]["discord"]["token"] == "${D}"
|
||||
|
||||
|
||||
def test_concurrent_edits_are_not_lost(config):
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
names = [f"ch{i}" for i in range(8)]
|
||||
with ThreadPoolExecutor(len(names)) as pool:
|
||||
list(pool.map(lambda n: edit_gateway_channel(config, n, {"platform": "telegram", "token": "${X}"}), names))
|
||||
assert set(_load(config)["channels"]) == {"telegram", *names}
|
||||
|
||||
|
||||
def test_missing_config_raises_without_lock_file(tmp_path):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
edit_gateway_channel(tmp_path / "nope.yaml", "discord", {})
|
||||
assert not list(tmp_path.iterdir())
|
||||
Reference in new issue
Block a user