Coverage for graphica/models/project.py: 98.7%

119 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-12 16:38 +0000

1import hashlib 

2import json 

3import os 

4import logging 

5import uuid 

6 

7from PySide6.QtCore import QObject, Signal 

8 

9from graphica.core.dataset import Dataset 

10from graphica.core.json_utils import GraphicaJSONEncoder 

11 

12logger = logging.getLogger(__name__) 

13 

14# 保存は .gra。.graphica(以前の拡張子)も中身は同じ JSON なので読み書きできる 

15PROJECT_FILE_EXTENSION = '.gra' 

16PROJECT_FILE_EXTENSIONS = ('.gra', '.graphica') 

17LEGACY_PICKLE_EXTENSION = '.pkl' 

18 

19 

20# format_version が無いファイルは 0。構造を変えたら上げて、前の版からの変換を _MIGRATIONS に足す 

21CURRENT_FORMAT_VERSION = 1 

22 

23 

24def _migrate_v0_to_v1(data): 

25 """0 から 1 は format_version を足しただけで、構造は同じ。""" 

26 return data 

27 

28 

29# from_version -> (from_version + 1) の形にする関数 

30_MIGRATIONS = { 

31 0: _migrate_v0_to_v1, 

32} 

33 

34 

35def _migrate_project_data(data): 

36 """CURRENT_FORMAT_VERSION まで順に変換する。新しい版のファイルはエラーにする(黙って半分だけ読まない)。""" 

37 version = data.get('format_version', 0) 

38 if version > CURRENT_FORMAT_VERSION: 

39 raise ValueError( 

40 f"このプロジェクトファイルはバージョン{version}で保存されていますが、" 

41 f"このアプリケーションが対応しているのはバージョン{CURRENT_FORMAT_VERSION}までです。" 

42 "アプリケーションを最新版に更新してください。" 

43 ) 

44 while version < CURRENT_FORMAT_VERSION: 

45 migrate = _MIGRATIONS.get(version) 

46 if migrate is None: 46 ↛ 47line 46 didn't jump to line 47 because the condition on line 46 was never true

47 raise ValueError(f"バージョン{version}からの移行手順が見つかりません。") 

48 data = migrate(data) 

49 version += 1 

50 return data 

51 

52 

53class ProjectModel(QObject): 

54 """1つの文書(データセット、軸ごとの設定、レイアウト)。 

55 

56 既存の変更箇所は PlotterApp._update_plot() を直接呼ぶ。changed は、そのメソッドを知らない新しい経路が 

57 再描画を頼むためのもので、今は誰も発行しない。 

58 """ 

59 

60 changed = Signal() 

61 

62 def __init__(self): 

63 super().__init__() 

64 # 最後に保存・読み込みしたパス(オートセーブでも変わる。上書き保存先は PlotterApp._current_project_path) 

65 self.current_filepath = "" 

66 

67 self.datasets = [] 

68 # {'name', 'children': [...]} の入れ子。子はフォルダか {'dataset': Dataset}。name='' のルートは表示しない 

69 self.dataset_group_tree = {'name': '', 'children': []} 

70 self.all_plot_settings = [] # 軸ごとの設定(core/axis_settings.py) 

71 self.active_axis_index = 0 

72 

73 self.layout_rows = 1 

74 self.layout_cols = 1 

75 # 'grid' か 'free'。'free' では all_plot_settings の数がサブプロットの数で、各設定の 

76 # 'free_rect' に (left, bottom, width, height) を 0〜1 で持つ 

77 self.layout_mode = 'grid' 

78 

79 # (a)(b)(c)… は描くときに並びから決めるので、文字は保存しない 

80 self.panel_labels_enabled = False 

81 

82 # 同じ行(sharex)・同じ列(sharey)のサブプロットの軸を束ね、内側の目盛りラベルを隠す。グリッドのときだけ 

83 self.share_x_axis = False 

84 self.share_y_axis = False 

85 

86 def notify_changed(self): 

87 self.changed.emit() 

88 

89 def save_project(self, filepath): 

90 """JSON で保存する(開いてもコードが実行されない)。拡張子は .gra か .graphica。""" 

91 ext = os.path.splitext(filepath)[1].lower() 

92 if ext not in PROJECT_FILE_EXTENSIONS: 

93 raise ValueError(f"サポートされていない拡張子です: {ext}") 

94 self._save_project_json(filepath) 

95 self.current_filepath = filepath 

96 

97 def load_project(self, filepath): 

98 if not os.path.exists(filepath): 

99 raise FileNotFoundError(f"ファイルが見つかりません: {filepath}") 

100 

101 ext = os.path.splitext(filepath)[1].lower() 

102 if ext == LEGACY_PICKLE_EXTENSION: 

103 raise ValueError("旧形式(.pkl)のプロジェクトには対応していません。") 

104 if ext not in PROJECT_FILE_EXTENSIONS: 

105 raise ValueError(f"サポートされていない拡張子です: {ext}") 

106 self._load_project_json(filepath) 

107 self.current_filepath = filepath 

108 

109 @staticmethod 

110 def _tree_to_json(node): 

111 """Dataset の参照を dataset_id の文字列にする(JSON にするため)。""" 

112 if 'dataset' in node: 

113 return {'dataset_id': node['dataset'].dataset_id} 

114 return { 

115 'name': node.get('name', ''), 

116 'children': [ProjectModel._tree_to_json(child) for child in node.get('children', [])], 

117 } 

118 

119 @staticmethod 

120 def _tree_from_json(node, dataset_map): 

121 """_tree_to_json() の逆。見つからない ID は警告して除く(壊れたファイルでも読めるように)。 

122 

123 dataset_map は ID -> まだ並びに置いていないデータセットの列。同じ ID の系列があれば(複製が ID まで写していた 

124 ころのファイル)、出てきた順に1本ずつ割り当てる。 

125 """ 

126 if 'dataset_id' in node: 

127 candidates = dataset_map.get(node['dataset_id']) 

128 ds = candidates.pop(0) if candidates else None 

129 if ds is None: 

130 logger.warning( 

131 "dataset_group_tree内に存在しないdataset_idがあるため、" 

132 "このリーフをスキップします: %s", node['dataset_id'] 

133 ) 

134 return None 

135 return {'dataset': ds} 

136 

137 children = [] 

138 for child in node.get('children', []): 

139 converted = ProjectModel._tree_from_json(child, dataset_map) 

140 if converted is not None: 

141 children.append(converted) 

142 return {'name': node.get('name', ''), 'children': children} 

143 

144 def _make_dataset_ids_unique(self): 

145 """同じ ID の2本目以降に新しい ID を振る(並びを決めてから。統計値ラベルなどの参照は1本目を指したまま)。""" 

146 seen = set() 

147 for ds in self.datasets: 

148 if ds.dataset_id in seen: 

149 logger.warning("同じ dataset_id のデータセットがあったため、新しい ID を振りました: %s", ds.name) 

150 ds.dataset_id = uuid.uuid4().hex 

151 seen.add(ds.dataset_id) 

152 

153 def content_fingerprint(self): 

154 """保存すると書き出される内容のハッシュ(未保存の変更の判定)。 

155 

156 Undo の clean 状態では判定しない(軸の設定・列の計算・フォルダ操作は Undo の対象外)。 

157 呼ぶ前に UI にしか無い状態を反映しておくこと(PlotterApp._sync_project_from_ui)。 

158 """ 

159 payload = self._json_payload() 

160 # 編集対象のサブプロットを切り替えただけで保存の確認が出ないように 

161 payload.pop('active_axis_index', None) 

162 text = json.dumps(payload, cls=GraphicaJSONEncoder, sort_keys=True, ensure_ascii=False) 

163 return hashlib.sha256(text.encode('utf-8')).hexdigest() 

164 

165 def _json_payload(self): 

166 """.graphica に書き出す辞書(保存と content_fingerprint で共有)。""" 

167 return { 

168 'format_version': CURRENT_FORMAT_VERSION, 

169 'datasets': [ds.to_dict() for ds in self.datasets], 

170 'dataset_group_tree': self._tree_to_json(self.dataset_group_tree), 

171 'all_plot_settings': self.all_plot_settings, 

172 'active_axis_index': self.active_axis_index, 

173 'layout_rows': self.layout_rows, 

174 'layout_cols': self.layout_cols, 

175 'layout_mode': self.layout_mode, 

176 'panel_labels_enabled': self.panel_labels_enabled, 

177 'share_x_axis': self.share_x_axis, 

178 'share_y_axis': self.share_y_axis, 

179 } 

180 

181 def _save_project_json(self, filepath): 

182 data = self._json_payload() 

183 # 名前に日本語が多いので \uXXXX にせず読める形で残す 

184 with open(filepath, 'w', encoding='utf-8') as f: 

185 json.dump(data, f, cls=GraphicaJSONEncoder, indent=2, ensure_ascii=False) 

186 

187 def _load_project_json(self, filepath): 

188 with open(filepath, 'r', encoding='utf-8') as f: 

189 data = json.load(f) 

190 

191 data = _migrate_project_data(data) 

192 

193 self.datasets = [Dataset.from_dict(d) for d in data.get('datasets', [])] 

194 dataset_map = {} 

195 for ds in self.datasets: 

196 dataset_map.setdefault(ds.dataset_id, []).append(ds) 

197 

198 tree_data = data.get('dataset_group_tree') 

199 if tree_data: 

200 self.dataset_group_tree = self._tree_from_json(tree_data, dataset_map) 

201 else: 

202 # キーが無ければ、.pkl と同じく全部をルートに置く 

203 self.dataset_group_tree = { 

204 'name': '', 'children': [{'dataset': ds} for ds in self.datasets] 

205 } 

206 self._make_dataset_ids_unique() 

207 

208 self.all_plot_settings = data.get('all_plot_settings', []) 

209 self.active_axis_index = data.get('active_axis_index', 0) 

210 self.layout_rows = data.get('layout_rows', 1) 

211 self.layout_cols = data.get('layout_cols', 1) 

212 self.layout_mode = data.get('layout_mode', 'grid') 

213 self.panel_labels_enabled = data.get('panel_labels_enabled', False) 

214 self.share_x_axis = data.get('share_x_axis', False) 

215 self.share_y_axis = data.get('share_y_axis', False)