Coverage for paintmaskanimator/config.py: 94%

79 statements  

« 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). 

2 

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 

14 

15from .constants import APP_NAME 

16 

17 

18def config_dir(): 

19 """Return the per-user config directory for the app (created on demand).""" 

20 return _config_paths()[0] 

21 

22 

23def config_path(): 

24 return _config_paths()[1] 

25 

26 

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 

44 

45 

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 

56 

57 

58def _now(): 

59 return time.monotonic() 

60 

61 

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"] 

89 

90 

91def load_config() -> Any: 

92 """Load the config dict; return {} if missing or unreadable.""" 

93 # 呼び出し側が書き換えてもキャッシュを汚さないよう、複製を返す。 

94 return copy.deepcopy(_cached_config()) 

95 

96 

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 

115 

116 

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 

122 

123 

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