Feature/delta team/epic r04 #7
@@ -0,0 +1,188 @@
|
||||
"""ConfigRepository chạy trên file JSON — R02-T02.
|
||||
|
||||
Thay cho ``config.py::AppConfig``. Hai khác biệt duy nhất về hành vi, cả hai
|
||||
đều là thứ ta muốn:
|
||||
|
||||
1. Ghi qua :class:`AtomicJsonFile` — mất điện giữa lúc lưu không còn làm hỏng
|
||||
cấu hình (R02-T01).
|
||||
2. API key đọc từ :class:`SecretStore` rồi **ghép vào** dict do
|
||||
``provider_conf()`` trả về — đúng đường A đã chốt 21/08
|
||||
(``docs/refactor/GammaTeam_decisions.md``). Nhờ vậy 5 nơi đang đọc
|
||||
``conf["api_key"]`` không phải sửa dòng nào, trong đó 3 nơi thuộc Team Duy.
|
||||
|
||||
Mọi thứ còn lại giữ nguyên có chủ đích: trộn sâu với mặc định, đọc biến môi
|
||||
trường, ``ms365.unlocked`` không bao giờ chạm đĩa. Đây là refactor — hành vi
|
||||
nhìn từ ngoài phải y hệt.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from ..persistence.json.atomic_json_file import AtomicJsonFile
|
||||
from ..secrets.secret_store import SecretStore, provider_key
|
||||
|
||||
|
||||
class JsonConfigRepository:
|
||||
"""Cấu hình đọc/ghi từ một file JSON, bí mật để trong ``SecretStore``.
|
||||
|
||||
``secrets`` để None nghĩa là không có kho bí mật — mọi thứ vẫn chạy, chỉ
|
||||
là ``api_key`` lấy nguyên từ file như trước. Cần vậy để chuyển dần
|
||||
(R02-T05) chứ không phải đổi một phát cả app.
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path, *, secrets: SecretStore | None = None,
|
||||
defaults: Dict[str, Any] | None = None,
|
||||
env_overrides=None):
|
||||
self._file = AtomicJsonFile(path)
|
||||
self._secrets = secrets
|
||||
# Lấy thẳng từ config.py để hai bên không lệch nhau trong lúc chuyển.
|
||||
if defaults is None or env_overrides is None:
|
||||
from ... import config as legacy
|
||||
defaults = defaults if defaults is not None else legacy.DEFAULT_CONFIG
|
||||
env_overrides = env_overrides or legacy._apply_env_overrides
|
||||
self._defaults = defaults
|
||||
self._env_overrides = env_overrides
|
||||
self.data: Dict[str, Any] = self._load()
|
||||
|
||||
# ---- nạp ------------------------------------------------------------
|
||||
def _load(self) -> Dict[str, Any]:
|
||||
merged = copy.deepcopy(self._defaults)
|
||||
stored = self._file.read(default=None)
|
||||
if isinstance(stored, dict):
|
||||
merged = _deep_merge(merged, stored)
|
||||
merged = self._env_overrides(merged)
|
||||
# Trạng thái mở khoá ms365 chỉ tồn tại lúc chạy — mỗi lần mở app đều
|
||||
# bắt đầu ở trạng thái khoá, không tin giá trị đọc từ đĩa.
|
||||
merged.setdefault("ms365", {})["unlocked"] = False
|
||||
return merged
|
||||
|
||||
def reload(self) -> None:
|
||||
self.data = self._load()
|
||||
|
||||
# ---- provider --------------------------------------------------------
|
||||
@property
|
||||
def active_provider(self) -> str:
|
||||
return self.data.get("active_provider", "")
|
||||
|
||||
def set_active_provider(self, name: str) -> None:
|
||||
self.data["active_provider"] = name
|
||||
|
||||
def provider_conf(self, name: str | None = None) -> Dict[str, Any]:
|
||||
"""Cấu hình provider, có sẵn ``api_key``.
|
||||
|
||||
Trả về BẢN SAO: chỗ gọi sửa dict này thì không được âm thầm ghi ngược
|
||||
vào cấu hình — và quan trọng hơn, khoá vừa ghép vào không được lẫn
|
||||
ngược vào ``self.data`` rồi theo ``save()`` xuống đĩa.
|
||||
"""
|
||||
name = name or self.active_provider
|
||||
conf = dict(self.data.get("providers", {}).get(name, {}))
|
||||
if self._secrets is not None:
|
||||
stored = self._secrets.get(provider_key(name))
|
||||
if stored:
|
||||
conf["api_key"] = stored
|
||||
return conf
|
||||
|
||||
def set_api_key(self, name: str, value: str) -> None:
|
||||
"""Lưu khoá vào kho bí mật, và xoá khỏi cấu hình trên đĩa.
|
||||
|
||||
Đây là nửa còn lại của đường A: dict *đọc ra* vẫn có ``api_key``,
|
||||
nhưng file JSON *trên đĩa* thì không — điều kiện để qua CASAN Check 1.
|
||||
"""
|
||||
if self._secrets is not None:
|
||||
self._secrets.set(provider_key(name), value)
|
||||
self.data.setdefault("providers", {}).setdefault(name, {})["api_key"] = ""
|
||||
else:
|
||||
self.data.setdefault("providers", {}).setdefault(name, {})["api_key"] = value
|
||||
|
||||
# ---- đường dẫn -------------------------------------------------------
|
||||
@property
|
||||
def shared_dir(self) -> str:
|
||||
return self.data.get("shared_dir", "")
|
||||
|
||||
def history_dir(self) -> Path:
|
||||
rt = self.data.get("_project_history_dir")
|
||||
if rt:
|
||||
return Path(rt)
|
||||
custom = (self.data.get("history", {}).get("custom_dir") or "").strip()
|
||||
if custom:
|
||||
return Path(custom).expanduser()
|
||||
from ...config import CONFIG_DIR
|
||||
return CONFIG_DIR / "history"
|
||||
|
||||
def cowork_output_dir(self) -> Path:
|
||||
custom = (self.data.get("cowork", {}).get("output_dir") or "").strip()
|
||||
if custom:
|
||||
return Path(custom).expanduser()
|
||||
from ... import paths
|
||||
from ...config import CONFIG_DIR
|
||||
root = paths.primary_onedrive_root()
|
||||
if root is not None:
|
||||
return root / "CoworkLocal" / "output"
|
||||
return CONFIG_DIR / "output" / "cowork"
|
||||
|
||||
# ---- giao diện -------------------------------------------------------
|
||||
@property
|
||||
def theme(self) -> str:
|
||||
return self.data.get("theme", "dark")
|
||||
|
||||
def set_theme(self, value: str) -> None:
|
||||
self.data["theme"] = value
|
||||
|
||||
@property
|
||||
def language(self) -> str:
|
||||
return self.data.get("language", "vi")
|
||||
|
||||
def set_language(self, value: str) -> None:
|
||||
self.data["language"] = value
|
||||
|
||||
# ---- nhóm cấu hình ---------------------------------------------------
|
||||
@property
|
||||
def routing(self) -> Dict[str, Any]:
|
||||
return self.data.setdefault("routing", {})
|
||||
|
||||
@property
|
||||
def auth(self) -> Dict[str, Any]:
|
||||
return self.data.setdefault("auth", {})
|
||||
|
||||
@property
|
||||
def agent_security(self) -> Dict[str, Any]:
|
||||
return self.data.setdefault("agent_security", {})
|
||||
|
||||
@property
|
||||
def tools_disabled(self) -> list[str]:
|
||||
return list(self.data.get("tools_disabled", []))
|
||||
|
||||
def set_tool_enabled(self, name: str, enabled: bool) -> None:
|
||||
disabled = list(self.data.get("tools_disabled", []))
|
||||
if enabled:
|
||||
disabled = [t for t in disabled if t != name]
|
||||
elif name not in disabled:
|
||||
disabled.append(name)
|
||||
self.data["tools_disabled"] = disabled
|
||||
|
||||
# ---- ghi -------------------------------------------------------------
|
||||
def save(self) -> None:
|
||||
"""Ghi nguyên tử. Không bao giờ để lộ trạng thái mở khoá ms365."""
|
||||
to_write = self.data
|
||||
if self.data.get("ms365", {}).get("unlocked"):
|
||||
to_write = copy.deepcopy(self.data)
|
||||
to_write["ms365"]["unlocked"] = False
|
||||
to_write.pop("_project_history_dir", None)
|
||||
self._file.write(to_write)
|
||||
|
||||
|
||||
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Trộn sâu — giống hệt ``config.py::_deep_merge``.
|
||||
|
||||
Không import lại từ đó vì file này phải sống được sau khi ``config.py``
|
||||
biến mất; giữ bản sao 6 dòng còn hơn giữ một sợi dây phụ thuộc.
|
||||
"""
|
||||
out = copy.deepcopy(base)
|
||||
for key, value in (override or {}).items():
|
||||
if isinstance(value, dict) and isinstance(out.get(key), dict):
|
||||
out[key] = _deep_merge(out[key], value)
|
||||
else:
|
||||
out[key] = value
|
||||
return out
|
||||
@@ -0,0 +1,155 @@
|
||||
"""JsonConfigRepository — R02-T02.
|
||||
|
||||
Hai nhóm bài:
|
||||
* **round-trip** — ghi rồi nạp lại phải ra đúng thứ đã ghi (cột nghiệm thu
|
||||
của plan.md cho ngày 22-23/08)
|
||||
* **đường A** — ``provider_conf()`` vẫn trả ``api_key``, nhưng file JSON
|
||||
trên đĩa thì không có, để qua CASAN Check 1
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.infrastructure.config.config_repository import ConfigRepository
|
||||
from cowork_local.infrastructure.config.json_config_repository import (
|
||||
JsonConfigRepository,
|
||||
)
|
||||
from cowork_local.tests.fakes.fake_config import FakeSecretStore
|
||||
|
||||
DEFAULTS = {
|
||||
"active_provider": "ollama",
|
||||
"providers": {
|
||||
"ollama": {"base_url": "http://localhost:11434/v1", "model": "llama3",
|
||||
"api_key": "ollama"},
|
||||
"openai": {"base_url": "https://api.openai.com/v1", "model": "gpt-4o-mini",
|
||||
"api_key": ""},
|
||||
},
|
||||
"theme": "dark", "language": "vi", "shared_dir": "",
|
||||
"routing": {"mode": "off"}, "auth": {}, "agent_security": {},
|
||||
"tools_disabled": [], "history": {}, "cowork": {}, "ms365": {},
|
||||
}
|
||||
|
||||
|
||||
def _repo(tmp_path, secrets=None):
|
||||
return JsonConfigRepository(tmp_path / "config.json", secrets=secrets,
|
||||
defaults=DEFAULTS, env_overrides=lambda d: d)
|
||||
|
||||
|
||||
def test_khop_hop_dong(tmp_path):
|
||||
assert isinstance(_repo(tmp_path), ConfigRepository)
|
||||
|
||||
|
||||
def test_chua_co_file_thi_dung_mac_dinh(tmp_path):
|
||||
cfg = _repo(tmp_path)
|
||||
assert cfg.active_provider == "ollama"
|
||||
assert cfg.theme == "dark"
|
||||
|
||||
|
||||
def test_round_trip(tmp_path):
|
||||
cfg = _repo(tmp_path)
|
||||
cfg.set_theme("light")
|
||||
cfg.set_language("en")
|
||||
cfg.set_active_provider("openai")
|
||||
cfg.set_tool_enabled("run_command", False)
|
||||
cfg.save()
|
||||
|
||||
lai = _repo(tmp_path)
|
||||
assert lai.theme == "light"
|
||||
assert lai.language == "en"
|
||||
assert lai.active_provider == "openai"
|
||||
assert lai.tools_disabled == ["run_command"]
|
||||
|
||||
|
||||
def test_gia_tri_luu_trong_file_trum_len_mac_dinh_nhung_giu_phan_con_thieu(tmp_path):
|
||||
"""Trộn sâu: file cũ thiếu khoá mới thì lấy mặc định, không mất phần cũ."""
|
||||
(tmp_path / "config.json").write_text(
|
||||
json.dumps({"theme": "light", "providers": {"openai": {"model": "gpt-5"}}}),
|
||||
encoding="utf-8")
|
||||
cfg = _repo(tmp_path)
|
||||
assert cfg.theme == "light" # từ file
|
||||
assert cfg.language == "vi" # từ mặc định
|
||||
assert cfg.provider_conf("openai")["model"] == "gpt-5" # từ file
|
||||
assert "api.openai.com" in cfg.provider_conf("openai")["base_url"] # mặc định
|
||||
|
||||
|
||||
# ---- đường A: khoá vào kho bí mật, nhưng dict vẫn có ------------------------
|
||||
|
||||
def test_provider_conf_van_tra_api_key_sau_khi_chuyen_vao_kho(tmp_path):
|
||||
"""Điểm mấu chốt của quyết định A: 5 nơi đọc conf['api_key'] không đổi."""
|
||||
secrets = FakeSecretStore()
|
||||
cfg = _repo(tmp_path, secrets)
|
||||
cfg.set_api_key("openai", "sk-that-bi-mat")
|
||||
|
||||
assert cfg.provider_conf("openai")["api_key"] == "sk-that-bi-mat"
|
||||
|
||||
|
||||
def test_khoa_khong_bao_gio_nam_tren_dia(tmp_path):
|
||||
"""Điều kiện qua CASAN Check 1."""
|
||||
secrets = FakeSecretStore()
|
||||
cfg = _repo(tmp_path, secrets)
|
||||
cfg.set_api_key("openai", "sk-that-bi-mat")
|
||||
cfg.save()
|
||||
|
||||
raw = (tmp_path / "config.json").read_text(encoding="utf-8")
|
||||
assert "sk-that-bi-mat" not in raw
|
||||
assert secrets.get("provider:openai") == "sk-that-bi-mat"
|
||||
|
||||
|
||||
def test_sua_dict_tra_ve_khong_lam_ban_cau_hinh(tmp_path):
|
||||
"""provider_conf trả bản sao — nếu trả tham chiếu thì khoá vừa ghép vào sẽ
|
||||
lẫn ngược vào self.data rồi theo save() xuống đĩa."""
|
||||
secrets = FakeSecretStore()
|
||||
cfg = _repo(tmp_path, secrets)
|
||||
cfg.set_api_key("openai", "sk-bi-mat")
|
||||
|
||||
conf = cfg.provider_conf("openai")
|
||||
conf["model"] = "bị sửa bậy"
|
||||
cfg.save()
|
||||
|
||||
raw = (tmp_path / "config.json").read_text(encoding="utf-8")
|
||||
assert "bị sửa bậy" not in raw
|
||||
assert "sk-bi-mat" not in raw
|
||||
|
||||
|
||||
def test_khong_co_kho_bi_mat_thi_van_chay_nhu_cu(tmp_path):
|
||||
"""Máy không có keyring: hành vi lùi về đúng như config.py hôm nay."""
|
||||
cfg = _repo(tmp_path, secrets=None)
|
||||
cfg.set_api_key("openai", "sk-nam-trong-file")
|
||||
cfg.save()
|
||||
|
||||
assert cfg.provider_conf("openai")["api_key"] == "sk-nam-trong-file"
|
||||
raw = (tmp_path / "config.json").read_text(encoding="utf-8")
|
||||
assert "sk-nam-trong-file" in raw # đúng như cũ, có đánh đổi rõ ràng
|
||||
|
||||
|
||||
# ---- giữ nguyên hành vi cũ --------------------------------------------------
|
||||
|
||||
def test_ms365_unlocked_khong_bao_gio_xuong_dia(tmp_path):
|
||||
cfg = _repo(tmp_path)
|
||||
cfg.data["ms365"]["unlocked"] = True
|
||||
cfg.save()
|
||||
|
||||
raw = json.loads((tmp_path / "config.json").read_text(encoding="utf-8"))
|
||||
assert raw["ms365"]["unlocked"] is False
|
||||
assert cfg.data["ms365"]["unlocked"] is True # trong bộ nhớ vẫn giữ
|
||||
|
||||
assert _repo(tmp_path).data["ms365"]["unlocked"] is False
|
||||
|
||||
|
||||
def test_ghi_hong_giua_chung_khong_lam_mat_cau_hinh(tmp_path, monkeypatch):
|
||||
"""Thừa hưởng từ AtomicJsonFile — kiểm lại ở tầng này cho chắc."""
|
||||
import os
|
||||
|
||||
cfg = _repo(tmp_path)
|
||||
cfg.set_theme("light")
|
||||
cfg.save()
|
||||
|
||||
monkeypatch.setattr(os, "replace",
|
||||
lambda *a, **k: (_ for _ in ()).throw(OSError("mất điện")))
|
||||
cfg.set_theme("hỏng")
|
||||
with pytest.raises(OSError):
|
||||
cfg.save()
|
||||
|
||||
assert _repo(tmp_path).theme == "light"
|
||||
Reference in New Issue
Block a user