f
This commit is contained in:
361
SYS/config.py
361
SYS/config.py
@@ -39,321 +39,78 @@ def clear_config_cache() -> None:
|
||||
_CONFIG_CACHE.clear()
|
||||
|
||||
|
||||
def _strip_inline_comment(line: str) -> str:
|
||||
# Strip comments in a way that's friendly to common .conf usage:
|
||||
# - Full-line comments starting with '#' or ';'
|
||||
# - Inline comments starting with '#' or ';' *outside quotes*
|
||||
# (e.g. dtype="float16" # optional)
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
return ""
|
||||
if stripped.startswith("#") or stripped.startswith(";"):
|
||||
return ""
|
||||
|
||||
in_single = False
|
||||
in_double = False
|
||||
for i, ch in enumerate(line):
|
||||
if ch == "'" and not in_double:
|
||||
in_single = not in_single
|
||||
continue
|
||||
if ch == '"' and not in_single:
|
||||
in_double = not in_double
|
||||
continue
|
||||
if in_single or in_double:
|
||||
continue
|
||||
|
||||
if ch in {"#", ";"}:
|
||||
# Treat as a comment start only when preceded by whitespace.
|
||||
# This keeps values like paths or tokens containing '#' working
|
||||
# when quoted, and reduces surprises for unquoted values.
|
||||
if i == 0 or line[i - 1].isspace():
|
||||
return line[:i].rstrip()
|
||||
|
||||
return line
|
||||
def reload_config(
|
||||
config_dir: Optional[Path] = None, filename: str = "medios.db"
|
||||
) -> Dict[str, Any]:
|
||||
_CONFIG_CACHE.pop("db_config", None)
|
||||
return load_config(config_dir=config_dir, filename=filename)
|
||||
|
||||
|
||||
def _parse_scalar(value: str) -> Any:
|
||||
v = value.strip()
|
||||
if not v:
|
||||
return ""
|
||||
def load_config(
|
||||
config_dir: Optional[Path] = None, filename: str = "medios.db"
|
||||
) -> Dict[str, Any]:
|
||||
# We no longer use config_dir or filename for the config file itself,
|
||||
# but we keep them in the signature for backward compatibility.
|
||||
cache_key = "db_config"
|
||||
|
||||
if cache_key in _CONFIG_CACHE:
|
||||
return _CONFIG_CACHE[cache_key]
|
||||
|
||||
if (v.startswith('"') and v.endswith('"')) or (v.startswith("'") and v.endswith("'")):
|
||||
return v[1:-1]
|
||||
# Load from database
|
||||
try:
|
||||
from SYS.database import get_config_all
|
||||
db_config = get_config_all()
|
||||
if db_config:
|
||||
_CONFIG_CACHE[cache_key] = db_config
|
||||
return db_config
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
low = v.lower()
|
||||
if low in {"true", "yes", "on", "1"}:
|
||||
return True
|
||||
if low in {"false", "no", "off", "0"}:
|
||||
return False
|
||||
|
||||
if re.fullmatch(r"-?\d+", v):
|
||||
try:
|
||||
return int(v)
|
||||
except Exception:
|
||||
return v
|
||||
if re.fullmatch(r"-?\d+\.\d+", v):
|
||||
try:
|
||||
return float(v)
|
||||
except Exception:
|
||||
return v
|
||||
|
||||
return v
|
||||
return {}
|
||||
|
||||
|
||||
def _set_nested(d: Dict[str, Any], dotted_key: str, value: Any) -> None:
|
||||
parts = [p for p in dotted_key.split(".") if p]
|
||||
if not parts:
|
||||
return
|
||||
cur: Dict[str, Any] = d
|
||||
for p in parts[:-1]:
|
||||
nxt = cur.get(p)
|
||||
if not isinstance(nxt, dict):
|
||||
nxt = {}
|
||||
cur[p] = nxt
|
||||
cur = nxt
|
||||
cur[parts[-1]] = value
|
||||
|
||||
|
||||
def _merge_dict_inplace(base: Dict[str, Any], patch: Dict[str, Any]) -> Dict[str, Any]:
|
||||
for k, v in patch.items():
|
||||
if isinstance(v, dict) and isinstance(base.get(k), dict):
|
||||
_merge_dict_inplace(base[k], v) # type: ignore[index]
|
||||
else:
|
||||
base[k] = v
|
||||
return base
|
||||
|
||||
|
||||
def _apply_conf_block(
|
||||
config: Dict[str, Any], kind: str, subtype: str, block: Dict[str, Any]
|
||||
def save_config(
|
||||
config: Dict[str, Any],
|
||||
config_dir: Optional[Path] = None,
|
||||
filename: str = "medios.db",
|
||||
) -> None:
|
||||
kind_l = str(kind).strip().lower()
|
||||
subtype_l = str(subtype).strip().lower()
|
||||
|
||||
if kind_l == "store":
|
||||
# Store instances are keyed by NAME (preferred). If a block uses `name=...`,
|
||||
# normalize it into NAME to keep a single canonical key.
|
||||
name = block.get("NAME")
|
||||
if not name:
|
||||
name = block.get("name")
|
||||
if name:
|
||||
block = dict(block)
|
||||
block.pop("name", None)
|
||||
block["NAME"] = name
|
||||
|
||||
if not name:
|
||||
return
|
||||
|
||||
name_l = str(name).strip().lower()
|
||||
payload = dict(block)
|
||||
store = config.setdefault("store", {})
|
||||
if not isinstance(store, dict):
|
||||
config["store"] = {}
|
||||
store = config["store"]
|
||||
bucket = store.setdefault(subtype_l, {})
|
||||
if not isinstance(bucket, dict):
|
||||
store[subtype_l] = {}
|
||||
bucket = store[subtype_l]
|
||||
existing = bucket.get(name_l)
|
||||
if isinstance(existing, dict):
|
||||
_merge_dict_inplace(existing, payload)
|
||||
else:
|
||||
bucket[name_l] = payload
|
||||
return
|
||||
|
||||
if kind_l == "provider":
|
||||
provider_name = str(subtype).strip().lower()
|
||||
provider = config.setdefault("provider", {})
|
||||
if not isinstance(provider, dict):
|
||||
config["provider"] = {}
|
||||
provider = config["provider"]
|
||||
existing = provider.get(provider_name)
|
||||
if isinstance(existing, dict):
|
||||
_merge_dict_inplace(existing, block)
|
||||
else:
|
||||
provider[provider_name] = dict(block)
|
||||
return
|
||||
|
||||
if kind_l == "tool":
|
||||
tool_name = str(subtype).strip().lower()
|
||||
if not tool_name:
|
||||
return
|
||||
tool = config.setdefault("tool", {})
|
||||
if not isinstance(tool, dict):
|
||||
config["tool"] = {}
|
||||
tool = config["tool"]
|
||||
existing = tool.get(tool_name)
|
||||
if isinstance(existing, dict):
|
||||
_merge_dict_inplace(existing, block)
|
||||
else:
|
||||
tool[tool_name] = dict(block)
|
||||
return
|
||||
|
||||
return
|
||||
|
||||
|
||||
def parse_conf_text(text: str, *, base: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Parse a lightweight .conf format into the app's config dict.
|
||||
|
||||
Supported patterns:
|
||||
- Top-level key/value: temp="./temp"
|
||||
- Sections: [store=hydrusnetwork] + name/access key/url lines
|
||||
- Sections: [provider=OpenLibrary] + email/password lines
|
||||
- Dotted keys: store.hydrusnetwork.<name>.url="http://..." (optional)
|
||||
"""
|
||||
config: Dict[str, Any] = dict(base or {})
|
||||
|
||||
current_kind: Optional[str] = None
|
||||
current_subtype: Optional[str] = None
|
||||
current_block: Dict[str, Any] = {}
|
||||
|
||||
def flush() -> None:
|
||||
nonlocal current_kind, current_subtype, current_block
|
||||
if current_kind and current_subtype and current_block:
|
||||
_apply_conf_block(config, current_kind, current_subtype, current_block)
|
||||
current_kind = None
|
||||
current_subtype = None
|
||||
current_block = {}
|
||||
|
||||
for raw_line in text.splitlines():
|
||||
line = _strip_inline_comment(raw_line)
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("[") and stripped.endswith("]"):
|
||||
flush()
|
||||
header = stripped[1:-1].strip()
|
||||
if "=" in header:
|
||||
k, v = header.split("=", 1)
|
||||
current_kind = k.strip()
|
||||
current_subtype = v.strip()
|
||||
"""Persist configuration to the database."""
|
||||
try:
|
||||
from SYS.database import save_config_value
|
||||
|
||||
for key, value in config.items():
|
||||
if key in ('store', 'provider', 'tool'):
|
||||
if isinstance(value, dict):
|
||||
for subtype, instances in value.items():
|
||||
if isinstance(instances, dict):
|
||||
# provider/tool are usually config[cat][subtype][key]
|
||||
# but store is config['store'][subtype][name][key]
|
||||
if key == 'store':
|
||||
for name, settings in instances.items():
|
||||
if isinstance(settings, dict):
|
||||
for k, v in settings.items():
|
||||
save_config_value(key, subtype, name, k, v)
|
||||
else:
|
||||
for k, v in instances.items():
|
||||
save_config_value(key, subtype, "default", k, v)
|
||||
else:
|
||||
# Unknown header style; ignore block
|
||||
current_kind = None
|
||||
current_subtype = None
|
||||
continue
|
||||
# global settings
|
||||
if not key.startswith("_"):
|
||||
save_config_value("global", "none", "none", key, value)
|
||||
except Exception as e:
|
||||
log(f"Failed to save config to database: {e}")
|
||||
|
||||
if "=" not in stripped:
|
||||
continue
|
||||
|
||||
key, value = stripped.split("=", 1)
|
||||
key = key.strip()
|
||||
parsed_val = _parse_scalar(value)
|
||||
|
||||
if current_kind and current_subtype:
|
||||
current_block[key] = parsed_val
|
||||
else:
|
||||
if "." in key:
|
||||
_set_nested(config, key, parsed_val)
|
||||
else:
|
||||
config[key] = parsed_val
|
||||
|
||||
flush()
|
||||
return config
|
||||
_CONFIG_CACHE["db_config"] = config
|
||||
|
||||
|
||||
def _load_conf_config(base_dir: Path, config_path: Path) -> Dict[str, Any]:
|
||||
config: Dict[str, Any] = {}
|
||||
raw = config_path.read_text(encoding="utf-8")
|
||||
config = parse_conf_text(raw, base=config)
|
||||
|
||||
conf_dir = base_dir / "config.d"
|
||||
if conf_dir.exists() and conf_dir.is_dir():
|
||||
for frag in sorted(conf_dir.glob("*.conf")):
|
||||
try:
|
||||
frag_raw = frag.read_text(encoding="utf-8")
|
||||
config = parse_conf_text(frag_raw, base=config)
|
||||
except OSError as exc:
|
||||
log(f"Failed to read {frag}: {exc}")
|
||||
|
||||
return config
|
||||
def load() -> Dict[str, Any]:
|
||||
"""Return the parsed configuration from database."""
|
||||
return load_config()
|
||||
|
||||
|
||||
def _format_conf_value(val: Any) -> str:
|
||||
if isinstance(val, bool):
|
||||
return "true" if val else "false"
|
||||
if isinstance(val, (int, float)):
|
||||
return str(val)
|
||||
if val is None:
|
||||
return '""'
|
||||
s = str(val)
|
||||
s = s.replace('"', '\\"')
|
||||
return f'"{s}"'
|
||||
|
||||
|
||||
def _serialize_conf(config: Dict[str, Any]) -> str:
|
||||
lines: list[str] = []
|
||||
|
||||
# Top-level scalars first
|
||||
for key in sorted(config.keys()):
|
||||
if key in {"store", "provider", "tool", "networking"}:
|
||||
continue
|
||||
value = config.get(key)
|
||||
if isinstance(value, dict):
|
||||
continue
|
||||
lines.append(f"{key}={_format_conf_value(value)}")
|
||||
|
||||
# Store blocks
|
||||
store = config.get("store")
|
||||
if isinstance(store, dict):
|
||||
for subtype in sorted(store.keys()):
|
||||
bucket = store.get(subtype)
|
||||
if not isinstance(bucket, dict):
|
||||
continue
|
||||
for name in sorted(bucket.keys()):
|
||||
block = bucket.get(name)
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
lines.append("")
|
||||
lines.append(f"[store={subtype}]")
|
||||
lines.append(f"name={_format_conf_value(name)}")
|
||||
|
||||
# Deduplicate keys case-insensitively and skip "name"
|
||||
seen_keys = {"NAME", "name"}
|
||||
for k in sorted(block.keys()):
|
||||
k_upper = k.upper()
|
||||
if k_upper in seen_keys:
|
||||
continue
|
||||
seen_keys.add(k_upper)
|
||||
lines.append(f"{k}={_format_conf_value(block.get(k))}")
|
||||
|
||||
# Provider blocks
|
||||
provider = config.get("provider")
|
||||
if isinstance(provider, dict):
|
||||
for prov in sorted(provider.keys()):
|
||||
block = provider.get(prov)
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
lines.append("")
|
||||
lines.append(f"[provider={prov}]")
|
||||
|
||||
seen_keys = set()
|
||||
for k in sorted(block.keys()):
|
||||
k_upper = k.upper()
|
||||
if k_upper in seen_keys:
|
||||
continue
|
||||
seen_keys.add(k_upper)
|
||||
lines.append(f"{k}={_format_conf_value(block.get(k))}")
|
||||
|
||||
# Tool blocks
|
||||
tool = config.get("tool")
|
||||
if isinstance(tool, dict):
|
||||
for name in sorted(tool.keys()):
|
||||
block = tool.get(name)
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
lines.append("")
|
||||
lines.append(f"[tool={name}]")
|
||||
|
||||
seen_keys = set()
|
||||
for k in sorted(block.keys()):
|
||||
k_upper = k.upper()
|
||||
if k_upper in seen_keys:
|
||||
continue
|
||||
seen_keys.add(k_upper)
|
||||
lines.append(f"{k}={_format_conf_value(block.get(k))}")
|
||||
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
def save(config: Dict[str, Any]) -> None:
|
||||
"""Persist *config* back to database."""
|
||||
save_config(config)
|
||||
|
||||
|
||||
def _make_cache_key(config_dir: Optional[Path], filename: str, actual_path: Optional[Path]) -> str:
|
||||
|
||||
Reference in New Issue
Block a user