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", "key": "api_key",
"label": "API Key", "label": "API Key",
"group": "Authentication",
"type": "text", # text|boolean|integer|float|path|secret|multiline
"default": "", "default": "",
"required": True, "required": True,
"secret": True, "secret": True,
"choices": ["Option 1", "Option 2"] "choices": ["Option 1", "Option 2"],
"placeholder": "Paste value here"
} }
""" """
return [] return []
+8 -3
View File
@@ -43,20 +43,25 @@ def global_config() -> List[Dict[str, Any]]:
{ {
"key": "debug", "key": "debug",
"label": "Debug Output", "label": "Debug Output",
"group": "Runtime",
"type": "boolean",
"default": "false", "default": "false",
"choices": ["true", "false"] "choices": ["true", "false"],
}, },
{ {
"key": "auto_update", "key": "auto_update",
"label": "Auto-Update", "label": "Auto-Update",
"group": "Runtime",
"type": "boolean",
"default": "true", "default": "true",
"choices": ["true", "false"] "choices": ["true", "false"],
}, },
{ {
"key": "table_appearance", "key": "table_appearance",
"label": "Table Appearance", "label": "Table Appearance",
"group": "Display",
"default": "rainbow", "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", "key": "PATH",
"label": "Store Location", "label": "Store Location",
"group": "Storage",
"type": "path", # text|boolean|integer|float|path|secret|multiline
"default": "", "default": "",
"required": True, "required": True,
"choices": ["/mnt/media", "/srv/data"] "choices": ["/mnt/media", "/srv/data"],
"placeholder": "/srv/data"
} }
""" """
return [] return []
+156 -315
View File
@@ -7,7 +7,7 @@ from textual import on, work
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
from textual.screen import ModalScreen 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 pathlib import Path
from SYS.config import ( from SYS.config import (
@@ -15,15 +15,24 @@ from SYS.config import (
save_config, save_config,
save_config_and_verify, save_config_and_verify,
reload_config, reload_config,
global_config,
count_changed_entries, count_changed_entries,
ConfigSaveConflict, ConfigSaveConflict,
coerce_config_value, coerce_config_value,
) )
from SYS.database import db from SYS.database import db
from SYS.logger import log, debug from SYS.logger import log, debug
from Store.registry import _discover_store_classes, _required_keys_for from SYS.plugin_config import (
from ProviderCore.registry import get_plugin, list_plugins 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.matrix_room_picker import MatrixRoomPicker
from TUI.modalscreen.selection_modal import SelectionModal from TUI.modalscreen.selection_modal import SelectionModal
import logging import logging
@@ -93,6 +102,18 @@ class ConfigModal(ModalScreen):
width: 1fr; width: 1fr;
} }
.config-textarea {
height: 8;
margin-bottom: 1;
}
.config-group {
color: $accent;
text-style: bold;
margin-top: 1;
margin-bottom: 1;
}
#config-actions { #config-actions {
height: 3; height: 3;
align: right middle; align: right middle;
@@ -277,18 +298,19 @@ class ConfigModal(ModalScreen):
def render_globals(self, container: ScrollableContainer) -> None: def render_globals(self, container: ScrollableContainer) -> None:
container.mount(Label("General Configuration", classes="config-label")) container.mount(Label("General Configuration", classes="config-label"))
# Get global schema schema = get_global_schema()
schema_map = {f["key"].lower(): f for f in global_config()} schema_map = {f["key"].lower(): f for f in schema}
existing_keys_lower = set() existing_keys_lower = set()
render_state = {"group": None, "mounted_any": False}
idx = 0 idx = 0
# Show fields defined in schema first # 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) existing_keys_lower.add(key_lower)
label_text = field_def.get("label") or field_def["key"] self._mount_schema_group(container, field_def, render_state)
choices = field_def.get("choices")
# Find current value (case-insensitive) # Find current value (case-insensitive)
current_val = None current_val = None
found_key = field_def["key"] found_key = field_def["key"]
@@ -301,33 +323,10 @@ class ConfigModal(ModalScreen):
if current_val is None: if current_val is None:
current_val = str(field_def.get("default") or "") current_val = str(field_def.get("default") or "")
container.mount(Label(label_text))
inp_id = f"global-{idx}" inp_id = f"global-{idx}"
self._input_id_map[inp_id] = found_key self._input_id_map[inp_id] = found_key
self._mount_schema_field(container, field_def, inp_id, current_val, allow_paste=True)
if choices: render_state["mounted_any"] = True
# 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"))
idx += 1 idx += 1
# Show any other top-level keys not in schema # 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")) row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
idx += 1 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: def render_stores(self, container: ScrollableContainer) -> None:
container.mount(Label("Configured Stores", classes="config-label")) container.mount(Label("Configured Stores", classes="config-label"))
stores = self.config_data.get("store", {}) stores = self.config_data.get("store", {})
@@ -423,54 +501,18 @@ class ConfigModal(ModalScreen):
def render_item_editor(self, container: ScrollableContainer) -> None: def render_item_editor(self, container: ScrollableContainer) -> None:
item_type = str(self.editing_item_type or "") item_type = str(self.editing_item_type or "")
item_name = str(self.editing_item_name or "") item_name = str(self.editing_item_name or "")
item_schema_map = get_item_schema_map(item_type, item_name)
provider_schema_map = {} render_state = {"group": None, "mounted_any": False}
# Parse item_type for store-{stype} or just provider # Parse item_type for store-{stype} or just provider
if item_type.startswith("store-"): if item_type.startswith("store-"):
stype = item_type.replace("store-", "") stype = item_type.replace("store-", "")
container.mount(Label(f"Editing Store: {item_name} ({stype})", classes="config-label")) container.mount(Label(f"Editing Store: {item_name} ({stype})", classes="config-label"))
section = self.config_data.get("store", {}).get(stype, {}).get(item_name, {}) 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: else:
container.mount(Label(f"Editing {item_type.capitalize()}: {item_name}", classes="config-label")) container.mount(Label(f"Editing {item_type.capitalize()}: {item_name}", classes="config-label"))
section = self.config_data.get(item_type, {}).get(item_name, {}) 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 # Use columns for better layout of inputs with paste buttons
container.mount(Label("Edit Settings")) container.mount(Label("Edit Settings"))
# render_item_editor will handle the inputs for us if we set these # render_item_editor will handle the inputs for us if we set these
@@ -504,144 +546,53 @@ class ConfigModal(ModalScreen):
label_text = k label_text = k
is_secret = False is_secret = False
choices = None choices = None
schema = provider_schema_map.get(k_upper) schema = item_schema_map.get(k_upper)
if schema: if schema:
label_text = schema.get("label") or k self._mount_schema_group(container, schema, render_state)
if schema.get("required"):
label_text += " *"
if schema.get("secret"): if schema.get("secret"):
is_secret = True is_secret = True
choices = schema.get("choices") choices = schema.get("choices")
container.mount(Label(label_text))
inp_id = f"item-{idx}" inp_id = f"item-{idx}"
self._input_id_map[inp_id] = k self._input_id_map[inp_id] = k
if schema:
if choices: self._mount_schema_field(container, schema, inp_id, v, allow_paste=True)
# 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)
else: else:
container.mount(Label(label_text))
row = Horizontal(classes="field-row") row = Horizontal(classes="field-row")
container.mount(row) container.mount(row)
inp = Input(value=str(v), id=inp_id, classes="config-input") inp = Input(value=str(v), id=inp_id, classes="config-input")
if is_secret: if is_secret:
inp.password = True inp.password = True
row.mount(inp) row.mount(inp)
render_state["mounted_any"] = True
idx += 1 idx += 1
# Add required/optional fields from schema that are missing # 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: if k_upper not in existing_keys_upper:
existing_keys_upper.add(k_upper) existing_keys_upper.add(k_upper)
key = field_def["key"] key = field_def["key"]
label_text = field_def.get("label") or key self._mount_schema_group(container, field_def, render_state)
if field_def.get("required"):
label_text += " *"
default_val = str(field_def.get("default") or "") default_val = str(field_def.get("default") or "")
choices = field_def.get("choices")
container.mount(Label(label_text))
inp_id = f"item-{idx}" inp_id = f"item-{idx}"
self._input_id_map[inp_id] = key self._input_id_map[inp_id] = key
self._mount_schema_field(container, field_def, inp_id, default_val, allow_paste=True)
if choices: render_state["mounted_any"] = True
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"))
idx += 1 idx += 1
# If it's a store, we might have required keys (legacy check fallback) for required_key in get_required_config_keys(item_type, item_name):
if item_type.startswith("store-"): if required_key.upper() in existing_keys_upper:
stype = item_type.replace("store-", "") continue
classes = _discover_store_classes() existing_keys_upper.add(required_key.upper())
if stype in classes: container.mount(Label(required_key))
required_keys = _required_keys_for(classes[stype]) inp_id = f"item-{idx}"
for rk in required_keys: self._input_id_map[inp_id] = required_key
# Case-insensitive deduplication (fix path vs PATH) row = Horizontal(classes="field-row")
if rk.upper() not in existing_keys_upper: container.mount(row)
container.mount(Label(rk)) row.mount(Input(value="", id=inp_id, classes="config-input"))
inp_id = f"item-{idx}" row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
self._input_id_map[inp_id] = rk idx += 1
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")
if ( if (
item_type == "provider" item_type == "provider"
@@ -875,48 +826,13 @@ class ConfigModal(ModalScreen):
self.notify(f"Save failed: {exc}", severity="error", timeout=10) self.notify(f"Save failed: {exc}", severity="error", timeout=10)
self.refresh_view() self.refresh_view()
elif bid == "add-store-btn": elif bid == "add-store-btn":
all_classes = _discover_store_classes() options = get_configurable_store_types()
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)
self.app.push_screen(SelectionModal("Select Store Type", options), callback=self.on_store_type_selected) self.app.push_screen(SelectionModal("Select Store Type", options), callback=self.on_store_type_selected)
elif bid == "add-provider-btn": elif bid == "add-provider-btn":
provider_names = list(list_plugins().keys()) options = get_configurable_provider_types()
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)
self.app.push_screen(SelectionModal("Select Plugin Type", options), callback=self.on_provider_type_selected) self.app.push_screen(SelectionModal("Select Plugin Type", options), callback=self.on_provider_type_selected)
elif bid == "add-tool-btn": elif bid == "add-tool-btn":
# Discover tool modules that advertise a config_schema() options = get_configurable_tool_types() or ["ytdlp"]
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"]
if options: if options:
options.sort() options.sort()
self.app.push_screen(SelectionModal("Select Tool", options), callback=self.on_tool_type_selected) 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] = {} self.config_data["store"][stype] = {}
# Default config for the new store # Default config for the new store
new_config = {"NAME": new_name} new_config = build_default_store_config(stype, 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] = ""
self.config_data["store"][stype][new_name] = new_config self.config_data["store"][stype][new_name] = new_config
self.editing_item_type = f"store-{stype}" 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 # For providers, they are usually top-level entries in 'provider' dict
if ptype not in self.config_data["provider"]: if ptype not in self.config_data["provider"]:
from ProviderCore.registry import get_plugin_class self.config_data["provider"][ptype] = build_default_provider_config(ptype)
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.editing_item_type = "provider" self.editing_item_type = "provider"
self.editing_item_name = ptype self.editing_item_name = ptype
@@ -1099,18 +978,7 @@ class ConfigModal(ModalScreen):
self.config_data["tool"] = {} self.config_data["tool"] = {}
if tname not in self.config_data["tool"]: if tname not in self.config_data["tool"]:
new_config = {} self.config_data["tool"][tname] = build_default_tool_config(tname)
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.editing_item_type = "tool" self.editing_item_type = "tool"
self.editing_item_name = tname self.editing_item_name = tname
@@ -1182,7 +1050,7 @@ class ConfigModal(ModalScreen):
def _synchronize_inputs_to_config(self) -> None: def _synchronize_inputs_to_config(self) -> None:
"""Capture current input/select values before saving.""" """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: for widget in widgets:
widget_id = widget.id widget_id = widget.id
if not widget_id or widget_id not in self._input_id_map: 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: if widget.value == Select.BLANK:
continue continue
value = widget.value value = widget.value
elif isinstance(widget, TextArea):
value = widget.text
else: else:
value = widget.value value = widget.value
@@ -1696,6 +1566,11 @@ class ConfigModal(ModalScreen):
if event.input.id: if event.input.id:
self._update_config_value(event.input.id, event.value) 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) @on(Select.Changed)
def on_select_changed(self, event: Select.Changed) -> None: def on_select_changed(self, event: Select.Changed) -> None:
if event.select.id: if event.select.id:
@@ -1860,52 +1735,18 @@ class ConfigModal(ModalScreen):
item_type = str(self.editing_item_type or "") item_type = str(self.editing_item_type or "")
item_name = str(self.editing_item_name or "") item_name = str(self.editing_item_name or "")
required_keys = []
section = {} section = {}
if item_type.startswith("store-"): if item_type.startswith("store-"):
stype = item_type.replace("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, {}) section = self.config_data.get("store", {}).get(stype, {}).get(item_name, {})
elif item_type == "provider": 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, {}) section = self.config_data.get("provider", {}).get(item_name, {})
elif item_type == "tool": 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, {}) section = self.config_data.get("tool", {}).get(item_name, {})
# Check required keys # 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 # Case-insensitive lookup for the required key in the current section
val = None val = None
rk_upper = rk.upper() 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") format_value = parsed.get("format")
capture_mode_value = _normalize_capture_mode(parsed.get("capture_mode")) 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: if not format_value:
try: try:
tool_cfg = config.get("tool", {}) if isinstance(config, dict) else {} 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: if not format_value:
format_value = "webp" 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") storage_value = parsed.get("storage")
selector_arg = parsed.get("selector") selector_arg = parsed.get("selector")
selectors = [selector_arg] if selector_arg else [] selectors = [selector_arg] if selector_arg else []
+29
View File
@@ -69,6 +69,7 @@ class PlaywrightDefaults:
viewport_height: int = 1080 viewport_height: int = 1080
navigation_timeout_ms: int = 90_000 navigation_timeout_ms: int = 90_000
ignore_https_errors: bool = True ignore_https_errors: bool = True
screenshot_quality: int = 8
ffmpeg_path: Optional[str] = None # Path to ffmpeg executable; auto-detected if None ffmpeg_path: Optional[str] = None # Path to ffmpeg executable; auto-detected if None
@@ -107,6 +108,7 @@ class PlaywrightTool:
- playwright.viewport_height=1200 - playwright.viewport_height=1200
- playwright.navigation_timeout_ms=90000 - playwright.navigation_timeout_ms=90000
- playwright.ignore_https_errors=true - playwright.ignore_https_errors=true
- playwright.screenshot_quality=8
- playwright.ffmpeg_path="/path/to/ffmpeg" (auto-detected if not set) - playwright.ffmpeg_path="/path/to/ffmpeg" (auto-detected if not set)
FFmpeg resolution (in order): FFmpeg resolution (in order):
@@ -176,6 +178,7 @@ class PlaywrightTool:
vw = _int("viewport_width", defaults.viewport_width) vw = _int("viewport_width", defaults.viewport_width)
vh = _int("viewport_height", defaults.viewport_height) vh = _int("viewport_height", defaults.viewport_height)
nav_timeout = _int("navigation_timeout_ms", defaults.navigation_timeout_ms) 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)) ignore_https = bool(_get("ignore_https_errors", defaults.ignore_https_errors))
@@ -231,6 +234,7 @@ class PlaywrightTool:
viewport_height=vh, viewport_height=vh,
navigation_timeout_ms=nav_timeout, navigation_timeout_ms=nav_timeout,
ignore_https_errors=ignore_https, ignore_https_errors=ignore_https,
screenshot_quality=screenshot_quality,
ffmpeg_path=ffmpeg_path, ffmpeg_path=ffmpeg_path,
) )
@@ -578,52 +582,77 @@ def config_schema() -> List[Dict[str, Any]]:
{ {
"key": "browser", "key": "browser",
"label": "Playwright browser", "label": "Playwright browser",
"group": "Browser",
"default": _defaults.browser, "default": _defaults.browser,
"choices": browser_choices, "choices": browser_choices,
}, },
{ {
"key": "headless", "key": "headless",
"label": "Headless", "label": "Headless",
"group": "Browser",
"type": "boolean",
"default": str(_defaults.headless), "default": str(_defaults.headless),
"choices": ["true", "false"], "choices": ["true", "false"],
}, },
{ {
"key": "user_agent", "key": "user_agent",
"label": "User Agent", "label": "User Agent",
"group": "Browser",
"default": "default", "default": "default",
"choices": ["default", "native", "custom"], "choices": ["default", "native", "custom"],
}, },
{ {
"key": "user_agent_custom", "key": "user_agent_custom",
"label": "Custom User Agent (used when User Agent = custom)", "label": "Custom User Agent (used when User Agent = custom)",
"group": "Browser",
"default": "", "default": "",
"placeholder": "Mozilla/5.0 ...",
}, },
{ {
"key": "viewport_width", "key": "viewport_width",
"label": "Viewport width", "label": "Viewport width",
"group": "Capture",
"type": "integer",
"default": _defaults.viewport_width, "default": _defaults.viewport_width,
"choices": viewport_width_choices, "choices": viewport_width_choices,
}, },
{ {
"key": "viewport_height", "key": "viewport_height",
"label": "Viewport height", "label": "Viewport height",
"group": "Capture",
"type": "integer",
"default": _defaults.viewport_height, "default": _defaults.viewport_height,
"choices": viewport_height_choices, "choices": viewport_height_choices,
}, },
{ {
"key": "navigation_timeout_ms", "key": "navigation_timeout_ms",
"label": "Navigation timeout (ms)", "label": "Navigation timeout (ms)",
"group": "Navigation",
"type": "integer",
"default": _defaults.navigation_timeout_ms, "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", "key": "ignore_https_errors",
"label": "Ignore HTTPS errors", "label": "Ignore HTTPS errors",
"group": "Navigation",
"type": "boolean",
"default": str(_defaults.ignore_https_errors), "default": str(_defaults.ignore_https_errors),
"choices": ["true", "false"], "choices": ["true", "false"],
}, },
{ {
"key": "ffmpeg_path", "key": "ffmpeg_path",
"label": "FFmpeg path (leave empty to use global/bundled)", "label": "FFmpeg path (leave empty to use global/bundled)",
"group": "Environment",
"type": "path",
"default": "", "default": "",
"placeholder": "C:/path/to/ffmpeg.exe",
}, },
] ]