mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 15:37:55 +08:00
core/config: add the [trash] section
`[trash]` owns the one policy `delete_path` needs from the human: whether a delete is a move to the desktop's trash (`enabled = true`, the default) or a removal that cannot be undone (`false`, which is what the tool did before). Unknown keys degrade to the defaults, the way `[mcp]` does, so a typo cannot silently switch a destructive policy on. `reload()` re-reads the section.
This commit is contained in:
@@ -25,6 +25,7 @@ from .models import ProviderConfig as ProviderConfig
|
|||||||
from .models import ReasoningEffortConfig as ReasoningEffortConfig
|
from .models import ReasoningEffortConfig as ReasoningEffortConfig
|
||||||
from .models import SkillDiscoverySource as SkillDiscoverySource
|
from .models import SkillDiscoverySource as SkillDiscoverySource
|
||||||
from .models import SkillsConfig as SkillsConfig
|
from .models import SkillsConfig as SkillsConfig
|
||||||
|
from .models import TrashConfig as TrashConfig
|
||||||
from .models import compose_agent_system_content as compose_agent_system_content
|
from .models import compose_agent_system_content as compose_agent_system_content
|
||||||
from .path import get_default_path as _get_default_path
|
from .path import get_default_path as _get_default_path
|
||||||
from .reasoning import ReasoningConfig as ReasoningConfig
|
from .reasoning import ReasoningConfig as ReasoningConfig
|
||||||
|
|||||||
@@ -304,6 +304,21 @@ class NotifyConfig(Struct, omit_defaults=True):
|
|||||||
protocol: str = ""
|
protocol: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class TrashConfig(Struct, omit_defaults=True):
|
||||||
|
"""Whether a delete lands in the system trash or is final.
|
||||||
|
|
||||||
|
``enabled`` (the default) sends every path ``delete_path`` removes to the
|
||||||
|
desktop's own trash — the freedesktop trash on Linux, the Finder trash on
|
||||||
|
macOS, the Recycle Bin on Windows — so a deletion can be undone there.
|
||||||
|
``false`` restores the old behaviour of removing the path outright.
|
||||||
|
|
||||||
|
Scratch paths under the OS temporary directory, and a call that passes
|
||||||
|
``permanent=true``, are deleted for good either way.
|
||||||
|
"""
|
||||||
|
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
class ModelConfig(Struct, omit_defaults=True):
|
class ModelConfig(Struct, omit_defaults=True):
|
||||||
"""Capability flags and optional routing overrides for a model.
|
"""Capability flags and optional routing overrides for a model.
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from .models import (
|
|||||||
PluginsConfig,
|
PluginsConfig,
|
||||||
Provider,
|
Provider,
|
||||||
SkillsConfig,
|
SkillsConfig,
|
||||||
|
TrashConfig,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -83,6 +84,22 @@ def _parse_notify(raw: dict[str, object]) -> NotifyConfig:
|
|||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_trash(raw: dict[str, object]) -> TrashConfig:
|
||||||
|
"""Parse the ``[trash]`` section, falling back to defaults.
|
||||||
|
|
||||||
|
Unknown keys are rejected like ``[mcp]`` does: a typo leaves deletes going
|
||||||
|
to the trash instead of half-applying a policy about destructive ones.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
config = msgspec.convert(raw, TrashConfig)
|
||||||
|
except msgspec.ValidationError:
|
||||||
|
return TrashConfig()
|
||||||
|
known = set(TrashConfig.__struct_fields__)
|
||||||
|
if set(raw) - known:
|
||||||
|
return TrashConfig()
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
def _parse_mcp(raw: dict[str, object]) -> McpConfig:
|
def _parse_mcp(raw: dict[str, object]) -> McpConfig:
|
||||||
"""Parse the ``[mcp]`` section, falling back to defaults.
|
"""Parse the ``[mcp]`` section, falling back to defaults.
|
||||||
|
|
||||||
@@ -178,6 +195,7 @@ class ConfigStore:
|
|||||||
_mcp: McpConfig
|
_mcp: McpConfig
|
||||||
_skills: SkillsConfig
|
_skills: SkillsConfig
|
||||||
_notify: NotifyConfig
|
_notify: NotifyConfig
|
||||||
|
_trash: TrashConfig
|
||||||
_providers: dict[str, Provider]
|
_providers: dict[str, Provider]
|
||||||
_bad_providers: dict[str, object]
|
_bad_providers: dict[str, object]
|
||||||
_recoverable_providers: dict[str, Provider]
|
_recoverable_providers: dict[str, Provider]
|
||||||
@@ -193,6 +211,7 @@ class ConfigStore:
|
|||||||
self._mcp = _parse_mcp(cast("dict[str, object]", raw.get("mcp", {})))
|
self._mcp = _parse_mcp(cast("dict[str, object]", raw.get("mcp", {})))
|
||||||
self._skills = _parse_skills(cast("dict[str, object]", raw.get("skills", {})))
|
self._skills = _parse_skills(cast("dict[str, object]", raw.get("skills", {})))
|
||||||
self._notify = _parse_notify(cast("dict[str, object]", raw.get("notify", {})))
|
self._notify = _parse_notify(cast("dict[str, object]", raw.get("notify", {})))
|
||||||
|
self._trash = _parse_trash(cast("dict[str, object]", raw.get("trash", {})))
|
||||||
self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(document)
|
self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(document)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -264,6 +283,13 @@ class ConfigStore:
|
|||||||
"""Typed notifications section (grace, channels, actions)."""
|
"""Typed notifications section (grace, channels, actions)."""
|
||||||
return self._notify
|
return self._notify
|
||||||
|
|
||||||
|
# -- trash (read-only) --
|
||||||
|
|
||||||
|
@property
|
||||||
|
def trash_config(self) -> TrashConfig:
|
||||||
|
"""Typed trash section (whether a delete can be undone)."""
|
||||||
|
return self._trash
|
||||||
|
|
||||||
def set_plugins_enable(self, names: Sequence[str]) -> PluginsConfig:
|
def set_plugins_enable(self, names: Sequence[str]) -> PluginsConfig:
|
||||||
"""Replace the plugin enable list (in-memory; call :meth:`write` to persist)."""
|
"""Replace the plugin enable list (in-memory; call :meth:`write` to persist)."""
|
||||||
cleaned = [name.strip() for name in names if name.strip()]
|
cleaned = [name.strip() for name in names if name.strip()]
|
||||||
@@ -469,6 +495,7 @@ class ConfigStore:
|
|||||||
self._networking = _parse_networking(cast("dict[str, object]", raw.get("networking", {})))
|
self._networking = _parse_networking(cast("dict[str, object]", raw.get("networking", {})))
|
||||||
self._mcp = _parse_mcp(cast("dict[str, object]", raw.get("mcp", {})))
|
self._mcp = _parse_mcp(cast("dict[str, object]", raw.get("mcp", {})))
|
||||||
self._notify = _parse_notify(cast("dict[str, object]", raw.get("notify", {})))
|
self._notify = _parse_notify(cast("dict[str, object]", raw.get("notify", {})))
|
||||||
|
self._trash = _parse_trash(cast("dict[str, object]", raw.get("trash", {})))
|
||||||
self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(self._document)
|
self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(self._document)
|
||||||
|
|
||||||
# -- internal sync helpers --
|
# -- internal sync helpers --
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from plyngent.config import load
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def test_trash_section_defaults(tmp_path: Path) -> None:
|
||||||
|
"""Deletes go to the system trash unless the section says otherwise."""
|
||||||
|
path = tmp_path / "c.toml"
|
||||||
|
_ = path.write_text("", encoding="utf-8")
|
||||||
|
assert load(path).trash_config.enabled is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_trash_section_parse(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "c.toml"
|
||||||
|
_ = path.write_text("[trash]\nenabled = false\n", encoding="utf-8")
|
||||||
|
assert load(path).trash_config.enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_trash_section_typo_falls_back(tmp_path: Path) -> None:
|
||||||
|
"""An unknown key or a bad type degrades to the defaults, like ``[mcp]``."""
|
||||||
|
path = tmp_path / "c.toml"
|
||||||
|
_ = path.write_text("[trash]\nenable = false\n", encoding="utf-8")
|
||||||
|
assert load(path).trash_config.enabled is True
|
||||||
|
|
||||||
|
path2 = tmp_path / "d.toml"
|
||||||
|
_ = path2.write_text('[trash]\nenabled = "no"\n', encoding="utf-8")
|
||||||
|
assert load(path2).trash_config.enabled is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_trash_section_survives_reload(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "c.toml"
|
||||||
|
_ = path.write_text("[trash]\nenabled = false\n", encoding="utf-8")
|
||||||
|
store = load(path)
|
||||||
|
assert store.trash_config.enabled is False
|
||||||
|
_ = path.write_text("[trash]\nenabled = true\n", encoding="utf-8")
|
||||||
|
store.reload()
|
||||||
|
assert store.trash_config.enabled is True
|
||||||
Reference in New Issue
Block a user