Coverage for paintmaskanimator/config.py: 94%
79 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
1"""Small per-user JSON config store (Qt-independent).
3Holds lightweight preferences (theme, workspace layout, log level, action-panel
4settings). Stored under the OS user-config directory so it survives reinstalls.
5No secret is kept here; the file is still written owner-only in case one is
6added later.
7"""
8import copy
9import json
10import os
11import time
12from pathlib import Path
13from typing import Any
15from .constants import APP_NAME
18def config_dir():
19 """Return the per-user config directory for the app (created on demand)."""
20 return _config_paths()[0]
23def config_path():
24 return _config_paths()[1]
27def _config_paths():
28 # 描画のたびに呼ばれるので、環境変数が同じなら組み立て済みのパスを使う。
29 env = os.environ.get("APPDATA" if os.name == "nt" else "XDG_CONFIG_HOME")
30 cached = _paths_by_env.get(env)
31 if cached is None:
32 if os.name == "nt": 32 ↛ 33line 32 didn't jump to line 33 because the condition on line 32 was never true
33 base = env or (Path.home() / "AppData" / "Roaming")
34 else:
35 base = env or (Path.home() / ".config")
36 path = Path(base) / APP_NAME
37 cached = (path, path / "config.json")
38 _paths_by_env[env] = cached
39 directory = cached[0]
40 if directory not in _created_dirs:
41 directory.mkdir(parents=True, exist_ok=True)
42 _created_dirs.add(directory)
43 return cached
46# 設定はテーマの色などで描画のたびに読まれる(タイムラインはセル1つごと)。
47# 毎回ファイルを開いて JSON を解析すると重いので、解析結果を覚えておく。
48# ファイルの場所・更新時刻・サイズが変わったら読み直すので、外から書き換えても
49# 反映される。
50_created_dirs = set()
51_paths_by_env = {}
52_cache = {"key": None, "data": {}, "path": None, "checked": 0.0}
53# 更新時刻の確認(stat)も描画のたびだと積もるので、この秒数に1回までにする。
54# このアプリ自身の保存はすぐに反映する(save_config がキャッシュを捨てる)。
55_CHECK_INTERVAL = 0.5
58def _now():
59 return time.monotonic()
62def _cached_config():
63 path = config_path()
64 now = _now()
65 if (
66 _cache["key"] is not None
67 and _cache["path"] == path
68 and now - _cache["checked"] < _CHECK_INTERVAL
69 ):
70 return _cache["data"]
71 _cache["path"] = path
72 _cache["checked"] = now
73 try:
74 stat = path.stat()
75 except OSError:
76 # ファイルがまだ無い(初回起動など)状態も覚えて、確認を間引く。
77 _cache["key"] = (str(path), None, None)
78 _cache["data"] = {}
79 return _cache["data"]
80 key = (str(path), stat.st_mtime_ns, stat.st_size)
81 if _cache["key"] != key:
82 try:
83 data = json.loads(path.read_text(encoding="utf-8"))
84 except (OSError, ValueError):
85 data = {}
86 _cache["key"] = key
87 _cache["data"] = data
88 return _cache["data"]
91def load_config() -> Any:
92 """Load the config dict; return {} if missing or unreadable."""
93 # 呼び出し側が書き換えてもキャッシュを汚さないよう、複製を返す。
94 return copy.deepcopy(_cached_config())
97def save_config(data):
98 """Persist the config dict atomically, owner-readable only."""
99 path = config_path()
100 # 起動中にフォルダーを消されても書き込めるように作り直す。
101 path.parent.mkdir(parents=True, exist_ok=True)
102 tmp = path.with_suffix(".json.tmp")
103 tmp.write_text(
104 json.dumps(data, ensure_ascii=False, indent=2),
105 encoding="utf-8",
106 )
107 # chmod is a no-op for practical purposes on Windows (and can fail on odd
108 # filesystems); a failure here must not lose the user's settings.
109 try:
110 os.chmod(tmp, 0o600)
111 except OSError:
112 pass
113 tmp.replace(path)
114 _cache["key"] = None
117def get_value(key, default=None) -> Any:
118 value = _cached_config().get(key, default)
119 if isinstance(value, (dict, list)):
120 return copy.deepcopy(value)
121 return value
124def set_value(key, value):
125 data = load_config()
126 if value is None:
127 data.pop(key, None)
128 else:
129 data[key] = value
130 save_config(data)
131 return data