feat(config): align settings UI and bind optional API endpoints to keys
- Rebuild General Settings on a shared label/field grid with Fusion controls - Store optional per-key endpoints and apply them on key select - Keep legacy string vault entries working and cover endpoint sync in tests
This commit is contained in:
parent
1502050577
commit
df04f44db4
4 changed files with 1443 additions and 1290 deletions
2514
gui/config_tab.py
2514
gui/config_tab.py
File diff suppressed because it is too large
Load diff
36
gui/main.py
36
gui/main.py
|
|
@ -804,6 +804,15 @@ class DazedMTLGUI(QMainWindow):
|
|||
font.setPointSize(max(6, int(9 * scale_factor)))
|
||||
app.setFont(font)
|
||||
|
||||
# Keep line edits / combos tall enough for the scaled font.
|
||||
from PyQt5.QtGui import QFontMetrics
|
||||
from PyQt5.QtWidgets import QComboBox, QDoubleSpinBox, QLineEdit, QSpinBox
|
||||
|
||||
control_h = max(30, QFontMetrics(font).height() + 14)
|
||||
for widget in app.allWidgets():
|
||||
if isinstance(widget, (QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox)):
|
||||
widget.setMinimumHeight(control_h)
|
||||
|
||||
# Scale inline font-size values in every widget's individual stylesheet.
|
||||
# We store the *original* stylesheet on each widget (using a Qt property)
|
||||
# so that re-scaling always starts from the unmodified values.
|
||||
|
|
@ -1309,6 +1318,7 @@ def main():
|
|||
migrate_app_settings()
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setStyle("Fusion")
|
||||
|
||||
# Windows taskbar groups by executable (python.exe) unless we set an explicit
|
||||
# AppUserModelID; combine with QApplication window icon so the pinned icon
|
||||
|
|
@ -1437,7 +1447,8 @@ def main():
|
|||
background-color: #404040;
|
||||
color: #ffffff;
|
||||
border: 1px solid #555555;
|
||||
padding: 5px 8px;
|
||||
padding: 1px 10px;
|
||||
min-height: 30px;
|
||||
border-radius: 2px;
|
||||
selection-background-color: #007acc;
|
||||
}
|
||||
|
|
@ -1448,7 +1459,8 @@ def main():
|
|||
background-color: #404040;
|
||||
color: #ffffff;
|
||||
border: 1px solid #555555;
|
||||
padding: 5px 8px;
|
||||
padding: 1px 10px;
|
||||
min-height: 30px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
QSpinBox:focus, QDoubleSpinBox:focus {
|
||||
|
|
@ -1468,30 +1480,24 @@ def main():
|
|||
background-color: #404040;
|
||||
color: #ffffff;
|
||||
border: 1px solid #555555;
|
||||
padding: 5px;
|
||||
padding-right: 30px;
|
||||
padding: 1px 8px;
|
||||
min-height: 30px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
QComboBox:focus {
|
||||
border: 1px solid #007acc;
|
||||
}
|
||||
QComboBox::drop-down {
|
||||
subcontrol-origin: padding;
|
||||
subcontrol-position: center right;
|
||||
width: 25px;
|
||||
subcontrol-origin: border;
|
||||
subcontrol-position: top right;
|
||||
width: 20px;
|
||||
border: none;
|
||||
border-left: 1px solid #555555;
|
||||
background-color: #555555;
|
||||
background-color: #4a4a4a;
|
||||
}
|
||||
QComboBox::drop-down:hover {
|
||||
background-color: #007acc;
|
||||
}
|
||||
QComboBox::down-arrow {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 6px solid #ffffff;
|
||||
}
|
||||
QComboBox QAbstractItemView {
|
||||
background-color: #404040;
|
||||
color: #ffffff;
|
||||
|
|
|
|||
|
|
@ -47,13 +47,18 @@ class ApiKeyVaultTests(unittest.TestCase):
|
|||
self.assertEqual(api_keys.get_active_secret(self.vault_path), "")
|
||||
|
||||
def test_migrate_from_env_when_vault_empty(self):
|
||||
self.env_path.write_text('key="sk-from-env"\n', encoding="utf-8")
|
||||
self.env_path.write_text(
|
||||
'key="sk-from-env"\napi="https://api.example.com/v1"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
vault = api_keys.migrate_from_env_if_empty(
|
||||
vault_path=self.vault_path,
|
||||
env_path=self.env_path,
|
||||
)
|
||||
self.assertEqual(vault["active"], api_keys.DEFAULT_KEY_NAME)
|
||||
self.assertEqual(vault["keys"][api_keys.DEFAULT_KEY_NAME], "sk-from-env")
|
||||
entry = vault["keys"][api_keys.DEFAULT_KEY_NAME]
|
||||
self.assertEqual(entry["secret"], "sk-from-env")
|
||||
self.assertEqual(entry["endpoint"], "https://api.example.com/v1")
|
||||
|
||||
# Second call must not overwrite existing vault entries.
|
||||
self.env_path.write_text('key="sk-other"\n', encoding="utf-8")
|
||||
|
|
@ -61,7 +66,9 @@ class ApiKeyVaultTests(unittest.TestCase):
|
|||
vault_path=self.vault_path,
|
||||
env_path=self.env_path,
|
||||
)
|
||||
self.assertEqual(vault2["keys"][api_keys.DEFAULT_KEY_NAME], "sk-from-env")
|
||||
self.assertEqual(
|
||||
vault2["keys"][api_keys.DEFAULT_KEY_NAME]["secret"], "sk-from-env"
|
||||
)
|
||||
|
||||
def test_migrate_skips_placeholder_env_key(self):
|
||||
vault = api_keys.migrate_from_env_if_empty(
|
||||
|
|
@ -71,7 +78,12 @@ class ApiKeyVaultTests(unittest.TestCase):
|
|||
self.assertEqual(vault["keys"], {})
|
||||
|
||||
def test_sync_active_to_env(self):
|
||||
api_keys.upsert_key("Work", "sk-work", path=self.vault_path)
|
||||
api_keys.upsert_key(
|
||||
"Work",
|
||||
"sk-work",
|
||||
endpoint="https://api.work.test/v1",
|
||||
path=self.vault_path,
|
||||
)
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
secret = api_keys.sync_active_to_env(
|
||||
vault_path=self.vault_path,
|
||||
|
|
@ -79,14 +91,67 @@ class ApiKeyVaultTests(unittest.TestCase):
|
|||
)
|
||||
self.assertEqual(secret, "sk-work")
|
||||
self.assertEqual(os.environ.get("key"), "sk-work")
|
||||
self.assertEqual(os.environ.get("api"), "https://api.work.test/v1")
|
||||
text = self.env_path.read_text(encoding="utf-8")
|
||||
self.assertIn("sk-work", text)
|
||||
self.assertIn("https://api.work.test/v1", text)
|
||||
|
||||
def test_sync_without_endpoint_leaves_api_alone(self):
|
||||
self.env_path.write_text('api="https://keep.me/v1"\nkey="old"\n', encoding="utf-8")
|
||||
api_keys.upsert_key("Local", "sk-local", path=self.vault_path)
|
||||
with patch.dict(os.environ, {"api": "https://keep.me/v1"}, clear=False):
|
||||
api_keys.sync_active_to_env(
|
||||
vault_path=self.vault_path,
|
||||
env_path=self.env_path,
|
||||
)
|
||||
text = self.env_path.read_text(encoding="utf-8")
|
||||
self.assertIn("https://keep.me/v1", text)
|
||||
self.assertEqual(os.environ.get("api"), "https://keep.me/v1")
|
||||
|
||||
def test_endpoint_roundtrip_and_legacy_string_entries(self):
|
||||
self.vault_path.write_text(
|
||||
json.dumps({"active": "Legacy", "keys": {"Legacy": "sk-legacy"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.assertEqual(api_keys.get_secret("Legacy", self.vault_path), "sk-legacy")
|
||||
self.assertEqual(api_keys.get_endpoint("Legacy", self.vault_path), "")
|
||||
|
||||
api_keys.upsert_key(
|
||||
"Claude",
|
||||
"sk-claude",
|
||||
endpoint="https://api.anthropic.com/v1",
|
||||
path=self.vault_path,
|
||||
)
|
||||
self.assertEqual(
|
||||
api_keys.get_endpoint("Claude", self.vault_path),
|
||||
"https://api.anthropic.com/v1",
|
||||
)
|
||||
data = json.loads(self.vault_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(data["keys"]["Claude"]["endpoint"], "https://api.anthropic.com/v1")
|
||||
# Legacy string entry is normalized on next save.
|
||||
api_keys.set_active("Legacy", path=self.vault_path)
|
||||
data2 = json.loads(self.vault_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(data2["keys"]["Legacy"]["secret"], "sk-legacy")
|
||||
|
||||
def test_keep_secret_if_blank_updates_endpoint(self):
|
||||
api_keys.upsert_key("A", "sk-a", path=self.vault_path)
|
||||
api_keys.upsert_key(
|
||||
"A",
|
||||
"",
|
||||
endpoint="https://api.a.test/v1",
|
||||
path=self.vault_path,
|
||||
keep_secret_if_blank=True,
|
||||
)
|
||||
self.assertEqual(api_keys.get_secret("A", self.vault_path), "sk-a")
|
||||
self.assertEqual(
|
||||
api_keys.get_endpoint("A", self.vault_path), "https://api.a.test/v1"
|
||||
)
|
||||
|
||||
def test_save_writes_json_and_restrictive_mode(self):
|
||||
api_keys.upsert_key("X", "sk-x", path=self.vault_path)
|
||||
data = json.loads(self.vault_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(data["active"], "X")
|
||||
self.assertEqual(data["keys"]["X"], "sk-x")
|
||||
self.assertEqual(data["keys"]["X"]["secret"], "sk-x")
|
||||
mode = self.vault_path.stat().st_mode & 0o777
|
||||
# Best-effort: owner read/write only when chmod is honored.
|
||||
self.assertEqual(mode & 0o077, 0)
|
||||
|
|
|
|||
108
util/api_keys.py
108
util/api_keys.py
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
Secrets live in ``data/api_keys.json`` (user-local). The active secret is also
|
||||
mirrored to ``.env`` ``key=`` so translation and subprocesses keep working.
|
||||
|
||||
Each named entry may optionally include an ``endpoint`` (API base URL). When the
|
||||
active key has one, it is mirrored to ``.env`` ``api=`` as well.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -22,6 +25,20 @@ def _empty_vault() -> dict[str, Any]:
|
|||
return {"active": "", "keys": {}}
|
||||
|
||||
|
||||
def _normalize_entry(raw: Any) -> dict[str, str]:
|
||||
"""Accept legacy plain-string secrets or ``{secret, endpoint}`` objects."""
|
||||
if isinstance(raw, str):
|
||||
return {"secret": raw, "endpoint": ""}
|
||||
if isinstance(raw, dict):
|
||||
secret = raw.get("secret", raw.get("key", ""))
|
||||
endpoint = raw.get("endpoint", raw.get("api", ""))
|
||||
return {
|
||||
"secret": "" if secret is None else str(secret),
|
||||
"endpoint": ("" if endpoint is None else str(endpoint)).strip(),
|
||||
}
|
||||
return {"secret": "", "endpoint": ""}
|
||||
|
||||
|
||||
def load_vault(path: Path | None = None) -> dict[str, Any]:
|
||||
"""Load vault JSON. Returns ``{"active": "", "keys": {}}`` when missing/invalid."""
|
||||
vault_path = path or API_KEYS_PATH
|
||||
|
|
@ -36,13 +53,14 @@ def load_vault(path: Path | None = None) -> dict[str, Any]:
|
|||
keys = data.get("keys")
|
||||
if not isinstance(keys, dict):
|
||||
keys = {}
|
||||
cleaned: dict[str, str] = {}
|
||||
for name, secret in keys.items():
|
||||
cleaned: dict[str, dict[str, str]] = {}
|
||||
for name, raw in keys.items():
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
continue
|
||||
if secret is None:
|
||||
entry = _normalize_entry(raw)
|
||||
if not entry["secret"]:
|
||||
continue
|
||||
cleaned[name.strip()] = str(secret)
|
||||
cleaned[name.strip()] = entry
|
||||
active = data.get("active") or ""
|
||||
if not isinstance(active, str):
|
||||
active = ""
|
||||
|
|
@ -58,9 +76,18 @@ def save_vault(vault: dict[str, Any], path: Path | None = None) -> None:
|
|||
"""Write vault JSON with restrictive permissions when the OS allows."""
|
||||
vault_path = path or API_KEYS_PATH
|
||||
vault_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
keys_out: dict[str, dict[str, str]] = {}
|
||||
for name, raw in dict(vault.get("keys") or {}).items():
|
||||
entry = _normalize_entry(raw)
|
||||
if not entry["secret"]:
|
||||
continue
|
||||
keys_out[str(name)] = {
|
||||
"secret": entry["secret"],
|
||||
"endpoint": entry["endpoint"],
|
||||
}
|
||||
payload = {
|
||||
"active": str(vault.get("active") or ""),
|
||||
"keys": dict(vault.get("keys") or {}),
|
||||
"keys": keys_out,
|
||||
}
|
||||
tmp = vault_path.with_suffix(vault_path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
|
|
@ -85,38 +112,73 @@ def get_active_name(path: Path | None = None) -> str:
|
|||
return str(load_vault(path).get("active") or "")
|
||||
|
||||
|
||||
def get_entry(name: str, path: Path | None = None) -> dict[str, str] | None:
|
||||
keys = load_vault(path).get("keys") or {}
|
||||
entry = keys.get(name)
|
||||
if entry is None:
|
||||
return None
|
||||
return _normalize_entry(entry)
|
||||
|
||||
|
||||
def get_active_secret(path: Path | None = None) -> str:
|
||||
vault = load_vault(path)
|
||||
active = vault.get("active") or ""
|
||||
keys = vault.get("keys") or {}
|
||||
if active and active in keys:
|
||||
return str(keys[active])
|
||||
return _normalize_entry(keys[active])["secret"]
|
||||
return ""
|
||||
|
||||
|
||||
def get_secret(name: str, path: Path | None = None) -> str | None:
|
||||
keys = load_vault(path).get("keys") or {}
|
||||
if name not in keys:
|
||||
entry = get_entry(name, path)
|
||||
if entry is None:
|
||||
return None
|
||||
return str(keys[name])
|
||||
return entry["secret"]
|
||||
|
||||
|
||||
def get_endpoint(name: str, path: Path | None = None) -> str | None:
|
||||
"""Return the optional endpoint for *name*, or None if the key is unknown."""
|
||||
entry = get_entry(name, path)
|
||||
if entry is None:
|
||||
return None
|
||||
return entry["endpoint"]
|
||||
|
||||
|
||||
def get_active_endpoint(path: Path | None = None) -> str:
|
||||
vault = load_vault(path)
|
||||
active = vault.get("active") or ""
|
||||
if not active:
|
||||
return ""
|
||||
return get_endpoint(active, path) or ""
|
||||
|
||||
|
||||
def upsert_key(
|
||||
name: str,
|
||||
secret: str,
|
||||
*,
|
||||
endpoint: str = "",
|
||||
make_active: bool = True,
|
||||
path: Path | None = None,
|
||||
keep_secret_if_blank: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Create or replace a named key. Optionally make it active."""
|
||||
"""Create or replace a named key. Optionally make it active.
|
||||
|
||||
When *keep_secret_if_blank* is true and *secret* is empty, an existing
|
||||
entry's secret is preserved (useful for editing endpoint only).
|
||||
"""
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("Key name is required")
|
||||
secret = (secret or "").strip()
|
||||
if not secret:
|
||||
raise ValueError("API key secret is required")
|
||||
endpoint = (endpoint or "").strip()
|
||||
vault = load_vault(path)
|
||||
vault["keys"][name] = secret
|
||||
existing = vault["keys"].get(name)
|
||||
if not secret:
|
||||
if keep_secret_if_blank and existing:
|
||||
secret = _normalize_entry(existing)["secret"]
|
||||
else:
|
||||
raise ValueError("API key secret is required")
|
||||
vault["keys"][name] = {"secret": secret, "endpoint": endpoint}
|
||||
if make_active or not vault.get("active"):
|
||||
vault["active"] = name
|
||||
save_vault(vault, path)
|
||||
|
|
@ -152,16 +214,21 @@ def sync_active_to_env(
|
|||
) -> str:
|
||||
"""Write the active vault secret to ``.env`` ``key=`` and ``os.environ``.
|
||||
|
||||
When the active key has an endpoint, also write ``api=``.
|
||||
Returns the secret that was written (may be empty).
|
||||
"""
|
||||
from dotenv import set_key
|
||||
|
||||
secret = get_active_secret(vault_path)
|
||||
endpoint = get_active_endpoint(vault_path)
|
||||
target = env_path or ENV_PATH
|
||||
if not target.exists():
|
||||
target.touch()
|
||||
set_key(target, "key", secret)
|
||||
os.environ["key"] = secret
|
||||
if endpoint:
|
||||
set_key(target, "api", endpoint)
|
||||
os.environ["api"] = endpoint
|
||||
return secret
|
||||
|
||||
|
||||
|
|
@ -170,24 +237,30 @@ def migrate_from_env_if_empty(
|
|||
vault_path: Path | None = None,
|
||||
env_path: Path | None = None,
|
||||
env_key: str | None = None,
|
||||
env_api: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""If the vault has no keys, import ``key`` from ``.env`` as ``Default``.
|
||||
"""If the vault has no keys, import ``key`` / ``api`` from ``.env`` as ``Default``.
|
||||
|
||||
``env_key`` overrides reading from disk (useful in tests).
|
||||
``env_key`` / ``env_api`` override reading from disk (useful in tests).
|
||||
"""
|
||||
vault = load_vault(vault_path)
|
||||
if vault["keys"]:
|
||||
return vault
|
||||
|
||||
secret = env_key
|
||||
if secret is None:
|
||||
endpoint = env_api
|
||||
if secret is None or endpoint is None:
|
||||
from dotenv import dotenv_values
|
||||
|
||||
target = env_path or ENV_PATH
|
||||
values = dotenv_values(target) if target.is_file() else {}
|
||||
secret = (values.get("key") or os.getenv("key") or "").strip()
|
||||
if secret is None:
|
||||
secret = (values.get("key") or os.getenv("key") or "").strip()
|
||||
if endpoint is None:
|
||||
endpoint = (values.get("api") or os.getenv("api") or "").strip()
|
||||
else:
|
||||
secret = str(secret).strip()
|
||||
endpoint = str(endpoint or "").strip()
|
||||
|
||||
if not secret or secret.startswith("<"):
|
||||
return vault
|
||||
|
|
@ -195,6 +268,7 @@ def migrate_from_env_if_empty(
|
|||
return upsert_key(
|
||||
DEFAULT_KEY_NAME,
|
||||
secret,
|
||||
endpoint=endpoint or "",
|
||||
make_active=True,
|
||||
path=vault_path,
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue