refactored config plugin defintions

This commit is contained in:
2026-04-21 14:18:52 -07:00
parent bc95a5c45d
commit 90787bd0a2
7 changed files with 439 additions and 321 deletions
+4 -1
View File
@@ -208,10 +208,13 @@ class Provider(ABC):
{
"key": "api_key",
"label": "API Key",
"group": "Authentication",
"type": "text", # text|boolean|integer|float|path|secret|multiline
"default": "",
"required": True,
"secret": True,
"choices": ["Option 1", "Option 2"]
"choices": ["Option 1", "Option 2"],
"placeholder": "Paste value here"
}
"""
return []
+8 -3
View File
@@ -43,20 +43,25 @@ def global_config() -> List[Dict[str, Any]]:
{
"key": "debug",
"label": "Debug Output",
"group": "Runtime",
"type": "boolean",
"default": "false",
"choices": ["true", "false"]
"choices": ["true", "false"],
},
{
"key": "auto_update",
"label": "Auto-Update",
"group": "Runtime",
"type": "boolean",
"default": "true",
"choices": ["true", "false"]
"choices": ["true", "false"],
},
{
"key": "table_appearance",
"label": "Table Appearance",
"group": "Display",
"default": "rainbow",
"choices": ["plain", "bw-striped", "rainbow"]
"choices": ["plain", "bw-striped", "rainbow"],
}
]
+223
View File
@@ -0,0 +1,223 @@
from __future__ import annotations
import importlib
import logging
import pkgutil
from typing import Any, Dict, Iterable, List, Optional
from SYS.config import global_config
from ProviderCore.registry import get_plugin_class, list_plugins
from Store.registry import _discover_store_classes, _required_keys_for
logger = logging.getLogger(__name__)
ConfigField = Dict[str, Any]
def _normalize_schema(fields: Optional[Iterable[Any]]) -> List[ConfigField]:
normalized: List[ConfigField] = []
seen: set[str] = set()
for raw_field in list(fields or []):
if not isinstance(raw_field, dict):
continue
key = str(raw_field.get("key") or "").strip()
if not key:
continue
key_upper = key.upper()
if key_upper in seen:
continue
seen.add(key_upper)
field = dict(raw_field)
field["key"] = key
if "label" in field and field.get("label") is not None:
field["label"] = str(field.get("label") or "")
choices = field.get("choices")
if choices is not None and not isinstance(choices, (list, tuple)):
field["choices"] = [choices]
elif isinstance(choices, tuple):
field["choices"] = list(choices)
normalized.append(field)
return normalized
def _call_schema(owner: Any, label: str) -> List[ConfigField]:
schema_fn = getattr(owner, "config_schema", None)
if not callable(schema_fn):
return []
try:
return _normalize_schema(schema_fn())
except Exception:
logger.exception("Failed to load config schema for %s", label)
return []
def get_store_schema(store_type: str) -> List[ConfigField]:
classes = _discover_store_classes()
cls = classes.get(str(store_type or "").strip())
if cls is None:
return []
return _call_schema(cls, f"store '{store_type}'")
def get_provider_schema(provider_name: str) -> List[ConfigField]:
plugin_class = get_plugin_class(str(provider_name or "").strip())
if plugin_class is None:
return []
return _call_schema(plugin_class, f"provider '{provider_name}'")
def get_tool_schema(tool_name: str) -> List[ConfigField]:
tool_name = str(tool_name or "").strip()
if not tool_name:
return []
try:
module = importlib.import_module(f"tool.{tool_name}")
except Exception:
logger.exception("Failed to import tool module 'tool.%s'", tool_name)
return []
return _call_schema(module, f"tool '{tool_name}'")
def get_item_schema(item_type: str, item_name: str) -> List[ConfigField]:
normalized_type = str(item_type or "").strip()
normalized_name = str(item_name or "").strip()
if normalized_type.startswith("store-"):
return get_store_schema(normalized_type.replace("store-", "", 1))
if normalized_type == "provider":
return get_provider_schema(normalized_name)
if normalized_type == "tool":
return get_tool_schema(normalized_name)
return []
def get_item_schema_map(item_type: str, item_name: str) -> Dict[str, ConfigField]:
return {field["key"].upper(): field for field in get_item_schema(item_type, item_name)}
def get_global_schema() -> List[ConfigField]:
return _normalize_schema(global_config())
def get_global_schema_map() -> Dict[str, ConfigField]:
return {field["key"].upper(): field for field in get_global_schema()}
def build_default_store_config(store_type: str, instance_name: str) -> Dict[str, Any]:
config: Dict[str, Any] = {"NAME": instance_name}
schema = get_store_schema(store_type)
if schema:
for field in schema:
key = field["key"]
if key.upper() == "NAME":
continue
config[key] = field.get("default", "")
return config
classes = _discover_store_classes()
cls = classes.get(str(store_type or "").strip())
if cls is None:
return config
for required_key in _required_keys_for(cls):
if required_key.upper() == "NAME":
continue
config[required_key] = ""
return config
def build_default_provider_config(provider_name: str) -> Dict[str, Any]:
config: Dict[str, Any] = {}
schema = get_provider_schema(provider_name)
if schema:
for field in schema:
config[field["key"]] = field.get("default", "")
return config
plugin_class = get_plugin_class(str(provider_name or "").strip())
if plugin_class is None:
return config
try:
for required_key in plugin_class.required_config_keys():
config[str(required_key)] = ""
except Exception:
logger.exception("Failed to load legacy required config keys for provider '%s'", provider_name)
return config
def build_default_tool_config(tool_name: str) -> Dict[str, Any]:
config: Dict[str, Any] = {}
for field in get_tool_schema(tool_name):
config[field["key"]] = field.get("default", "")
return config
def get_required_config_keys(item_type: str, item_name: str) -> List[str]:
normalized_type = str(item_type or "").strip()
normalized_name = str(item_name or "").strip()
required_keys: List[str] = []
seen: set[str] = set()
def _add_key(value: Any) -> None:
key = str(value or "").strip()
if not key:
return
key_upper = key.upper()
if key_upper in seen:
return
seen.add(key_upper)
required_keys.append(key)
for field in get_item_schema(normalized_type, normalized_name):
if field.get("required"):
_add_key(field.get("key"))
if normalized_type.startswith("store-"):
store_type = normalized_type.replace("store-", "", 1)
classes = _discover_store_classes()
cls = classes.get(store_type)
if cls is not None:
for required_key in _required_keys_for(cls):
_add_key(required_key)
elif normalized_type == "provider":
plugin_class = get_plugin_class(normalized_name)
if plugin_class is not None:
try:
for required_key in plugin_class.required_config_keys():
_add_key(required_key)
except Exception:
logger.exception("Failed to load required config keys for provider '%s'", normalized_name)
return required_keys
def get_configurable_store_types() -> List[str]:
options: List[str] = []
for store_type in _discover_store_classes().keys():
if get_store_schema(store_type):
options.append(str(store_type))
return sorted(set(options))
def get_configurable_provider_types() -> List[str]:
options: List[str] = []
for provider_name in list_plugins().keys():
if get_provider_schema(provider_name):
options.append(str(provider_name))
return sorted(set(options))
def get_configurable_tool_types() -> List[str]:
options: List[str] = []
try:
import tool as tool_package
for module_info in pkgutil.iter_modules(tool_package.__path__):
tool_name = str(module_info.name or "").strip()
if not tool_name:
continue
if get_tool_schema(tool_name):
options.append(tool_name)
except Exception:
logger.exception("Failed to discover configurable tool modules")
return sorted(set(options))
+4 -1
View File
@@ -20,9 +20,12 @@ class Store(ABC):
{
"key": "PATH",
"label": "Store Location",
"group": "Storage",
"type": "path", # text|boolean|integer|float|path|secret|multiline
"default": "",
"required": True,
"choices": ["/mnt/media", "/srv/data"]
"choices": ["/mnt/media", "/srv/data"],
"placeholder": "/srv/data"
}
"""
return []
+156 -315
View File
@@ -7,7 +7,7 @@ from textual import on, work
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
from textual.screen import ModalScreen
from textual.widgets import Static, Button, Input, Label, ListView, ListItem, Rule, Select, Checkbox
from textual.widgets import Static, Button, Input, Label, ListView, ListItem, Rule, Select, Checkbox, TextArea
from pathlib import Path
from SYS.config import (
@@ -15,15 +15,24 @@ from SYS.config import (
save_config,
save_config_and_verify,
reload_config,
global_config,
count_changed_entries,
ConfigSaveConflict,
coerce_config_value,
)
from SYS.database import db
from SYS.logger import log, debug
from Store.registry import _discover_store_classes, _required_keys_for
from ProviderCore.registry import get_plugin, list_plugins
from SYS.plugin_config import (
build_default_provider_config,
build_default_store_config,
build_default_tool_config,
get_configurable_provider_types,
get_configurable_store_types,
get_configurable_tool_types,
get_global_schema,
get_item_schema_map,
get_required_config_keys,
)
from ProviderCore.registry import get_plugin
from TUI.modalscreen.matrix_room_picker import MatrixRoomPicker
from TUI.modalscreen.selection_modal import SelectionModal
import logging
@@ -93,6 +102,18 @@ class ConfigModal(ModalScreen):
width: 1fr;
}
.config-textarea {
height: 8;
margin-bottom: 1;
}
.config-group {
color: $accent;
text-style: bold;
margin-top: 1;
margin-bottom: 1;
}
#config-actions {
height: 3;
align: right middle;
@@ -277,18 +298,19 @@ class ConfigModal(ModalScreen):
def render_globals(self, container: ScrollableContainer) -> None:
container.mount(Label("General Configuration", classes="config-label"))
# Get global schema
schema_map = {f["key"].lower(): f for f in global_config()}
schema = get_global_schema()
schema_map = {f["key"].lower(): f for f in schema}
existing_keys_lower = set()
render_state = {"group": None, "mounted_any": False}
idx = 0
# Show fields defined in schema first
for key_lower, field_def in schema_map.items():
for field_def in schema:
key_lower = field_def["key"].lower()
existing_keys_lower.add(key_lower)
label_text = field_def.get("label") or field_def["key"]
choices = field_def.get("choices")
self._mount_schema_group(container, field_def, render_state)
# Find current value (case-insensitive)
current_val = None
found_key = field_def["key"]
@@ -301,33 +323,10 @@ class ConfigModal(ModalScreen):
if current_val is None:
current_val = str(field_def.get("default") or "")
container.mount(Label(label_text))
inp_id = f"global-{idx}"
self._input_id_map[inp_id] = found_key
if choices:
# Normalize boolean-like choices to lowercase ('true'/'false') to avoid duplicate choices
normalized_choices = []
for c in choices:
s = str(c)
if s.lower() in ("true", "false"):
normalized_choices.append(s.lower())
else:
normalized_choices.append(s)
select_options = [(str(c), str(c)) for c in normalized_choices]
# Normalize current value as well
cur_val = str(current_val) if current_val is not None else ""
if cur_val.lower() in ("true", "false"):
cur_val = cur_val.lower()
if cur_val not in normalized_choices:
select_options.insert(0, (cur_val, cur_val))
sel = Select(select_options, value=cur_val, id=inp_id)
container.mount(sel)
else:
row = Horizontal(classes="field-row")
container.mount(row)
row.mount(Input(value=current_val, id=inp_id, classes="config-input"))
self._mount_schema_field(container, field_def, inp_id, current_val, allow_paste=True)
render_state["mounted_any"] = True
idx += 1
# Show any other top-level keys not in schema
@@ -345,6 +344,85 @@ class ConfigModal(ModalScreen):
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
idx += 1
def _mount_schema_group(self, container: ScrollableContainer, field_def: Dict[str, Any], state: Dict[str, Any]) -> None:
group_name = str(field_def.get("group") or "").strip()
if not group_name:
return
if state.get("group") == group_name:
return
if state.get("mounted_any"):
container.mount(Rule())
container.mount(Label(group_name, classes="config-group"))
state["group"] = group_name
def _normalized_select_options(self, choices: Any, current_val: Any) -> tuple[list[tuple[str, str]], str]:
select_options: list[tuple[str, str]] = []
choice_values: list[str] = []
for choice in list(choices or []):
if isinstance(choice, tuple) and len(choice) == 2:
label = str(choice[0])
value = str(choice[1])
else:
label = str(choice)
value = str(choice)
if value.lower() in ("true", "false"):
value = value.lower()
label = value
select_options.append((label, value))
choice_values.append(value)
normalized_current = str(current_val) if current_val is not None else ""
if normalized_current.lower() in ("true", "false"):
normalized_current = normalized_current.lower()
if normalized_current not in choice_values:
select_options.insert(0, (normalized_current, normalized_current))
return select_options, normalized_current
def _mount_schema_field(
self,
container: ScrollableContainer,
field_def: Dict[str, Any],
widget_id: str,
current_value: Any,
*,
allow_paste: bool,
) -> None:
label_text = str(field_def.get("label") or field_def.get("key") or widget_id)
if field_def.get("required"):
label_text += " *"
container.mount(Label(label_text))
choices = field_def.get("choices")
field_type = str(field_def.get("type") or "").strip().lower()
if choices:
select_options, normalized_current = self._normalized_select_options(choices, current_value)
container.mount(Select(select_options, value=normalized_current, id=widget_id))
return
if field_type in {"multiline", "textarea"}:
text_area = TextArea(str(current_value or ""), id=widget_id, classes="config-textarea")
placeholder = field_def.get("placeholder")
if placeholder:
try:
text_area.tooltip = str(placeholder)
except Exception:
pass
container.mount(text_area)
return
row = Horizontal(classes="field-row")
container.mount(row)
input_widget = Input(value=str(current_value or ""), id=widget_id, classes="config-input")
if field_def.get("secret") or field_type == "secret":
input_widget.password = True
if field_def.get("placeholder"):
input_widget.placeholder = str(field_def.get("placeholder") or "")
elif field_type == "path":
input_widget.placeholder = "Path"
row.mount(input_widget)
if allow_paste:
row.mount(Button("Paste", id=f"paste-{widget_id}", classes="paste-btn"))
def render_stores(self, container: ScrollableContainer) -> None:
container.mount(Label("Configured Stores", classes="config-label"))
stores = self.config_data.get("store", {})
@@ -423,54 +501,18 @@ class ConfigModal(ModalScreen):
def render_item_editor(self, container: ScrollableContainer) -> None:
item_type = str(self.editing_item_type or "")
item_name = str(self.editing_item_name or "")
provider_schema_map = {}
item_schema_map = get_item_schema_map(item_type, item_name)
render_state = {"group": None, "mounted_any": False}
# Parse item_type for store-{stype} or just provider
if item_type.startswith("store-"):
stype = item_type.replace("store-", "")
container.mount(Label(f"Editing Store: {item_name} ({stype})", classes="config-label"))
section = self.config_data.get("store", {}).get(stype, {}).get(item_name, {})
# Fetch Store schema
classes = _discover_store_classes()
if stype in classes:
cls = classes[stype]
if hasattr(cls, "config_schema") and callable(cls.config_schema):
for field_def in cls.config_schema():
k = field_def.get("key")
if k:
provider_schema_map[k.upper()] = field_def
else:
container.mount(Label(f"Editing {item_type.capitalize()}: {item_name}", classes="config-label"))
section = self.config_data.get(item_type, {}).get(item_name, {})
# Fetch Provider schema
if item_type == "provider":
from ProviderCore.registry import get_plugin_class
try:
pcls = get_plugin_class(item_name)
if pcls and hasattr(pcls, "config_schema") and callable(pcls.config_schema):
for field_def in pcls.config_schema():
k = field_def.get("key")
if k:
provider_schema_map[k.upper()] = field_def
except Exception:
logger.exception("Failed to retrieve provider config_schema")
# Fetch Tool schema
if item_type == "tool":
try:
import importlib
mod = importlib.import_module(f"tool.{item_name}")
if hasattr(mod, "config_schema") and callable(mod.config_schema):
for field_def in mod.config_schema():
k = field_def.get("key")
if k:
provider_schema_map[k.upper()] = field_def
except Exception:
logger.exception("Failed to retrieve tool config_schema")
# Use columns for better layout of inputs with paste buttons
container.mount(Label("Edit Settings"))
# render_item_editor will handle the inputs for us if we set these
@@ -504,144 +546,53 @@ class ConfigModal(ModalScreen):
label_text = k
is_secret = False
choices = None
schema = provider_schema_map.get(k_upper)
schema = item_schema_map.get(k_upper)
if schema:
label_text = schema.get("label") or k
if schema.get("required"):
label_text += " *"
self._mount_schema_group(container, schema, render_state)
if schema.get("secret"):
is_secret = True
choices = schema.get("choices")
container.mount(Label(label_text))
inp_id = f"item-{idx}"
self._input_id_map[inp_id] = k
if choices:
# Select takes a list of (label, value) tuples; normalize boolean-like values
select_options = []
choice_values = []
for c in choices:
if isinstance(c, tuple) and len(c) == 2:
label = str(c[0])
val = str(c[1])
else:
label = str(c)
val = str(c)
if val.lower() in ("true", "false"):
val = val.lower()
label = val
select_options.append((label, val))
choice_values.append(val)
# If current value not in choices, add it or stay blank
current_val = str(v)
if current_val.lower() in ("true", "false"):
current_val = current_val.lower()
if current_val not in choice_values:
select_options.insert(0, (current_val, current_val))
sel = Select(select_options, value=current_val, id=inp_id)
container.mount(sel)
if schema:
self._mount_schema_field(container, schema, inp_id, v, allow_paste=True)
else:
container.mount(Label(label_text))
row = Horizontal(classes="field-row")
container.mount(row)
inp = Input(value=str(v), id=inp_id, classes="config-input")
if is_secret:
inp.password = True
row.mount(inp)
render_state["mounted_any"] = True
idx += 1
# Add required/optional fields from schema that are missing
for k_upper, field_def in provider_schema_map.items():
for k_upper, field_def in item_schema_map.items():
if k_upper not in existing_keys_upper:
existing_keys_upper.add(k_upper)
key = field_def["key"]
label_text = field_def.get("label") or key
if field_def.get("required"):
label_text += " *"
self._mount_schema_group(container, field_def, render_state)
default_val = str(field_def.get("default") or "")
choices = field_def.get("choices")
container.mount(Label(label_text))
inp_id = f"item-{idx}"
self._input_id_map[inp_id] = key
if choices:
select_options = []
choice_values = []
for c in choices:
if isinstance(c, tuple) and len(c) == 2:
label = str(c[0])
val = str(c[1])
else:
label = str(c)
val = str(c)
if val.lower() in ("true", "false"):
val = val.lower()
label = val
select_options.append((label, val))
choice_values.append(val)
# Normalize default/current value
current_val = str(default_val) if default_val is not None else ""
if current_val.lower() in ("true", "false"):
current_val = current_val.lower()
if current_val not in choice_values:
select_options.insert(0, (current_val, current_val))
sel = Select(select_options, value=current_val, id=inp_id)
container.mount(sel)
else:
row = Horizontal(classes="field-row")
container.mount(row)
inp = Input(value=default_val, id=inp_id, classes="config-input")
if field_def.get("secret"):
inp.password = True
if field_def.get("placeholder"):
inp.placeholder = field_def.get("placeholder")
row.mount(inp)
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
self._mount_schema_field(container, field_def, inp_id, default_val, allow_paste=True)
render_state["mounted_any"] = True
idx += 1
# If it's a store, we might have required keys (legacy check fallback)
if item_type.startswith("store-"):
stype = item_type.replace("store-", "")
classes = _discover_store_classes()
if stype in classes:
required_keys = _required_keys_for(classes[stype])
for rk in required_keys:
# Case-insensitive deduplication (fix path vs PATH)
if rk.upper() not in existing_keys_upper:
container.mount(Label(rk))
inp_id = f"item-{idx}"
self._input_id_map[inp_id] = rk
row = Horizontal(classes="field-row")
container.mount(row)
row.mount(Input(value="", id=inp_id, classes="config-input"))
idx += 1
# If it's a provider, we might have required keys (legacy check fallback)
if item_type == "provider":
# 2. Legacy required_config_keys
from ProviderCore.registry import get_plugin_class
try:
pcls = get_plugin_class(item_name)
if pcls:
required_keys = pcls.required_config_keys()
for rk in required_keys:
if rk.upper() not in existing_keys_upper:
container.mount(Label(rk))
inp_id = f"item-{idx}"
self._input_id_map[inp_id] = rk
row = Horizontal(classes="field-row")
container.mount(row)
row.mount(Input(value="", id=inp_id, classes="config-input"))
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
idx += 1
except Exception:
logger.exception("Failed to build required config inputs for provider/tool")
for required_key in get_required_config_keys(item_type, item_name):
if required_key.upper() in existing_keys_upper:
continue
existing_keys_upper.add(required_key.upper())
container.mount(Label(required_key))
inp_id = f"item-{idx}"
self._input_id_map[inp_id] = required_key
row = Horizontal(classes="field-row")
container.mount(row)
row.mount(Input(value="", id=inp_id, classes="config-input"))
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
idx += 1
if (
item_type == "provider"
@@ -875,48 +826,13 @@ class ConfigModal(ModalScreen):
self.notify(f"Save failed: {exc}", severity="error", timeout=10)
self.refresh_view()
elif bid == "add-store-btn":
all_classes = _discover_store_classes()
options = []
for stype, cls in all_classes.items():
if hasattr(cls, "config_schema") and callable(cls.config_schema):
try:
if cls.config_schema():
options.append(stype)
except Exception:
logger.exception("Failed to inspect store class config_schema for '%s'", stype)
options = get_configurable_store_types()
self.app.push_screen(SelectionModal("Select Store Type", options), callback=self.on_store_type_selected)
elif bid == "add-provider-btn":
provider_names = list(list_plugins().keys())
options = []
from ProviderCore.registry import get_plugin_class
for ptype in provider_names:
pcls = get_plugin_class(ptype)
if pcls and hasattr(pcls, "config_schema") and callable(pcls.config_schema):
try:
if pcls.config_schema():
options.append(ptype)
except Exception:
logger.exception("Failed to inspect provider class config_schema for '%s'", ptype)
options = get_configurable_provider_types()
self.app.push_screen(SelectionModal("Select Plugin Type", options), callback=self.on_provider_type_selected)
elif bid == "add-tool-btn":
# Discover tool modules that advertise a config_schema()
options = []
try:
import pkgutil
import importlib
import tool as _tool_pkg
for _mod in pkgutil.iter_modules(_tool_pkg.__path__):
try:
mod = importlib.import_module(f"tool.{_mod.name}")
if hasattr(mod, "config_schema") and callable(mod.config_schema):
options.append(_mod.name)
except Exception:
continue
except Exception:
# Fallback to known entry
options = ["ytdlp"]
options = get_configurable_tool_types() or ["ytdlp"]
if options:
options.sort()
self.app.push_screen(SelectionModal("Select Tool", options), callback=self.on_tool_type_selected)
@@ -1033,26 +949,7 @@ class ConfigModal(ModalScreen):
self.config_data["store"][stype] = {}
# Default config for the new store
new_config = {"NAME": new_name}
classes = _discover_store_classes()
if stype in classes:
cls = classes[stype]
# Use schema for defaults if present
if hasattr(cls, "config_schema") and callable(cls.config_schema):
for field_def in cls.config_schema():
key = field_def.get("key")
if key:
val = field_def.get("default", "")
# Don't override NAME if we already set it to new_stype
if key.upper() == "NAME":
continue
new_config[key] = val
else:
# Fallback to required keys list
required = _required_keys_for(cls)
for rk in required:
if rk.upper() != "NAME":
new_config[rk] = ""
new_config = build_default_store_config(stype, new_name)
self.config_data["store"][stype][new_name] = new_config
self.editing_item_type = f"store-{stype}"
@@ -1067,25 +964,7 @@ class ConfigModal(ModalScreen):
# For providers, they are usually top-level entries in 'provider' dict
if ptype not in self.config_data["provider"]:
from ProviderCore.registry import get_plugin_class
try:
pcls = get_plugin_class(ptype)
new_config = {}
if pcls:
# Use schema for defaults
if hasattr(pcls, "config_schema") and callable(pcls.config_schema):
for field_def in pcls.config_schema():
key = field_def.get("key")
if key:
new_config[key] = field_def.get("default", "")
else:
# Fallback to legacy required keys
required = pcls.required_config_keys()
for rk in required:
new_config[rk] = ""
self.config_data["provider"][ptype] = new_config
except Exception:
self.config_data["provider"][ptype] = {}
self.config_data["provider"][ptype] = build_default_provider_config(ptype)
self.editing_item_type = "provider"
self.editing_item_name = ptype
@@ -1099,18 +978,7 @@ class ConfigModal(ModalScreen):
self.config_data["tool"] = {}
if tname not in self.config_data["tool"]:
new_config = {}
try:
import importlib
mod = importlib.import_module(f"tool.{tname}")
if hasattr(mod, "config_schema") and callable(mod.config_schema):
for field_def in mod.config_schema():
key = field_def.get("key")
if key:
new_config[key] = field_def.get("default", "")
except Exception:
logger.exception("Failed to load config_schema for tool '%s'", tname)
self.config_data["tool"][tname] = new_config
self.config_data["tool"][tname] = build_default_tool_config(tname)
self.editing_item_type = "tool"
self.editing_item_name = tname
@@ -1182,7 +1050,7 @@ class ConfigModal(ModalScreen):
def _synchronize_inputs_to_config(self) -> None:
"""Capture current input/select values before saving."""
widgets = list(self.query(Input)) + list(self.query(Select))
widgets = list(self.query(Input)) + list(self.query(Select)) + list(self.query(TextArea))
for widget in widgets:
widget_id = widget.id
if not widget_id or widget_id not in self._input_id_map:
@@ -1192,6 +1060,8 @@ class ConfigModal(ModalScreen):
if widget.value == Select.BLANK:
continue
value = widget.value
elif isinstance(widget, TextArea):
value = widget.text
else:
value = widget.value
@@ -1696,6 +1566,11 @@ class ConfigModal(ModalScreen):
if event.input.id:
self._update_config_value(event.input.id, event.value)
@on(TextArea.Changed)
def on_text_area_changed(self, event: TextArea.Changed) -> None:
if event.text_area.id:
self._update_config_value(event.text_area.id, event.text_area.text)
@on(Select.Changed)
def on_select_changed(self, event: Select.Changed) -> None:
if event.select.id:
@@ -1860,52 +1735,18 @@ class ConfigModal(ModalScreen):
item_type = str(self.editing_item_type or "")
item_name = str(self.editing_item_name or "")
required_keys = []
section = {}
if item_type.startswith("store-"):
stype = item_type.replace("store-", "")
classes = _discover_store_classes()
if stype in classes:
required_keys = list(_required_keys_for(classes[stype]))
section = self.config_data.get("store", {}).get(stype, {}).get(item_name, {})
elif item_type == "provider":
from ProviderCore.registry import get_plugin_class
try:
pcls = get_plugin_class(item_name)
if pcls:
# Collect required keys from schema
if hasattr(pcls, "config_schema") and callable(pcls.config_schema):
for field_def in pcls.config_schema():
if field_def.get("required"):
k = field_def.get("key")
if k and k not in required_keys:
required_keys.append(k)
# Merge with legacy required keys
for rk in pcls.required_config_keys():
if rk not in required_keys:
required_keys.append(rk)
except Exception:
logger.exception("Failed to inspect provider class '%s' for required keys", item_name)
section = self.config_data.get("provider", {}).get(item_name, {})
elif item_type == "tool":
try:
import importlib
mod = importlib.import_module(f"tool.{item_name}")
if hasattr(mod, "config_schema") and callable(mod.config_schema):
for field_def in mod.config_schema():
if field_def.get("required"):
k = field_def.get("key")
if k and k not in required_keys:
required_keys.append(k)
except Exception:
logger.exception("Failed to inspect tool module 'tool.%s' for required keys", item_name)
section = self.config_data.get("tool", {}).get(item_name, {})
# Check required keys
for rk in required_keys:
for rk in get_required_config_keys(item_type, item_name):
# Case-insensitive lookup for the required key in the current section
val = None
rk_upper = rk.upper()
+15 -1
View File
@@ -1684,7 +1684,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
format_value = parsed.get("format")
capture_mode_value = _normalize_capture_mode(parsed.get("capture_mode"))
quality_value = _normalize_quality(parsed.get("quality"))
raw_quality_value = parsed.get("quality")
quality_value: Optional[int] = None
if not format_value:
try:
tool_cfg = config.get("tool", {}) if isinstance(config, dict) else {}
@@ -1696,6 +1697,19 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
if not format_value:
format_value = "webp"
if raw_quality_value not in (None, ""):
quality_value = _normalize_quality(raw_quality_value)
else:
try:
tool_cfg = config.get("tool", {}) if isinstance(config, dict) else {}
pw_cfg = tool_cfg.get("playwright") if isinstance(tool_cfg, dict) else None
if isinstance(pw_cfg, dict) and pw_cfg.get("screenshot_quality") not in (None, ""):
quality_value = _normalize_quality(pw_cfg.get("screenshot_quality"))
except Exception:
quality_value = None
if quality_value is None:
quality_value = _normalize_quality(None)
storage_value = parsed.get("storage")
selector_arg = parsed.get("selector")
selectors = [selector_arg] if selector_arg else []
+29
View File
@@ -69,6 +69,7 @@ class PlaywrightDefaults:
viewport_height: int = 1080
navigation_timeout_ms: int = 90_000
ignore_https_errors: bool = True
screenshot_quality: int = 8
ffmpeg_path: Optional[str] = None # Path to ffmpeg executable; auto-detected if None
@@ -107,6 +108,7 @@ class PlaywrightTool:
- playwright.viewport_height=1200
- playwright.navigation_timeout_ms=90000
- playwright.ignore_https_errors=true
- playwright.screenshot_quality=8
- playwright.ffmpeg_path="/path/to/ffmpeg" (auto-detected if not set)
FFmpeg resolution (in order):
@@ -176,6 +178,7 @@ class PlaywrightTool:
vw = _int("viewport_width", defaults.viewport_width)
vh = _int("viewport_height", defaults.viewport_height)
nav_timeout = _int("navigation_timeout_ms", defaults.navigation_timeout_ms)
screenshot_quality = max(1, min(10, _int("screenshot_quality", defaults.screenshot_quality)))
ignore_https = bool(_get("ignore_https_errors", defaults.ignore_https_errors))
@@ -231,6 +234,7 @@ class PlaywrightTool:
viewport_height=vh,
navigation_timeout_ms=nav_timeout,
ignore_https_errors=ignore_https,
screenshot_quality=screenshot_quality,
ffmpeg_path=ffmpeg_path,
)
@@ -578,52 +582,77 @@ def config_schema() -> List[Dict[str, Any]]:
{
"key": "browser",
"label": "Playwright browser",
"group": "Browser",
"default": _defaults.browser,
"choices": browser_choices,
},
{
"key": "headless",
"label": "Headless",
"group": "Browser",
"type": "boolean",
"default": str(_defaults.headless),
"choices": ["true", "false"],
},
{
"key": "user_agent",
"label": "User Agent",
"group": "Browser",
"default": "default",
"choices": ["default", "native", "custom"],
},
{
"key": "user_agent_custom",
"label": "Custom User Agent (used when User Agent = custom)",
"group": "Browser",
"default": "",
"placeholder": "Mozilla/5.0 ...",
},
{
"key": "viewport_width",
"label": "Viewport width",
"group": "Capture",
"type": "integer",
"default": _defaults.viewport_width,
"choices": viewport_width_choices,
},
{
"key": "viewport_height",
"label": "Viewport height",
"group": "Capture",
"type": "integer",
"default": _defaults.viewport_height,
"choices": viewport_height_choices,
},
{
"key": "navigation_timeout_ms",
"label": "Navigation timeout (ms)",
"group": "Navigation",
"type": "integer",
"default": _defaults.navigation_timeout_ms,
},
{
"key": "screenshot_quality",
"label": "Default screenshot quality",
"group": "Capture",
"type": "integer",
"default": _defaults.screenshot_quality,
"choices": list(range(1, 11)),
},
{
"key": "ignore_https_errors",
"label": "Ignore HTTPS errors",
"group": "Navigation",
"type": "boolean",
"default": str(_defaults.ignore_https_errors),
"choices": ["true", "false"],
},
{
"key": "ffmpeg_path",
"label": "FFmpeg path (leave empty to use global/bundled)",
"group": "Environment",
"type": "path",
"default": "",
"placeholder": "C:/path/to/ffmpeg.exe",
},
]