utils: use absolute paths when opening files on Windows
[git-cola.git] / cola / settings.py
blob2702d95d6cdecbaa16f260271dff2def95343707
1 """Save settings, bookmarks, etc."""
2 import json
3 import os
4 import sys
6 from . import core
7 from . import display
8 from . import git
9 from . import resources
12 def mkdict(obj):
13 """Transform None and non-dicts into dicts"""
14 if isinstance(obj, dict):
15 value = obj
16 else:
17 value = {}
18 return value
21 def mklist(obj):
22 """Transform None and non-lists into lists"""
23 if isinstance(obj, list):
24 value = obj
25 elif isinstance(obj, tuple):
26 value = list(obj)
27 else:
28 value = []
29 return value
32 def read_json(path):
33 try:
34 with core.open_read(path) as f:
35 return mkdict(json.load(f))
36 except (ValueError, TypeError, OSError): # bad path or json
37 return {}
40 def write_json(values, path, sync=True):
41 """Write the specified values dict to a JSON file at the specified path"""
42 try:
43 parent = os.path.dirname(path)
44 if not core.isdir(parent):
45 core.makedirs(parent)
46 with core.open_write(path) as fp:
47 json.dump(values, fp, indent=4)
48 if sync:
49 core.fsync(fp.fileno())
50 except (ValueError, TypeError, OSError):
51 sys.stderr.write('git-cola: error writing "%s"\n' % path)
52 return False
53 return True
56 def rename_path(old, new):
57 """Rename a filename. Catch exceptions and return False on error."""
58 try:
59 core.rename(old, new)
60 except OSError:
61 sys.stderr.write(f'git-cola: error renaming "{old}" to "{new}"\n')
62 return False
63 return True
66 def remove_path(path):
67 """Remove a filename. Report errors to stderr."""
68 try:
69 core.remove(path)
70 except OSError:
71 sys.stderr.write('git-cola: error removing "%s"\n' % path)
74 class Settings:
75 config_path = resources.config_home('settings')
76 bookmarks = property(lambda self: mklist(self.values['bookmarks']))
77 gui_state = property(lambda self: mkdict(self.values['gui_state']))
78 recent = property(lambda self: mklist(self.values['recent']))
79 copy_formats = property(lambda self: mklist(self.values['copy_formats']))
81 def __init__(self, verify=git.is_git_worktree):
82 """Load existing settings if they exist"""
83 self.values = {
84 'bookmarks': [],
85 'gui_state': {},
86 'recent': [],
87 'copy_formats': [],
89 self.verify = verify
91 def remove_missing_bookmarks(self):
92 """Remove "favorites" bookmarks that no longer exist"""
93 missing_bookmarks = []
94 for bookmark in self.bookmarks:
95 if not self.verify(bookmark['path']):
96 missing_bookmarks.append(bookmark)
98 for bookmark in missing_bookmarks:
99 try:
100 self.bookmarks.remove(bookmark)
101 except ValueError:
102 pass
104 def remove_missing_recent(self):
105 """Remove "recent" repositories that no longer exist"""
106 missing_recent = []
107 for recent in self.recent:
108 if not self.verify(recent['path']):
109 missing_recent.append(recent)
111 for recent in missing_recent:
112 try:
113 self.recent.remove(recent)
114 except ValueError:
115 pass
117 def add_bookmark(self, path, name):
118 """Adds a bookmark to the saved settings"""
119 bookmark = {'path': display.normalize_path(path), 'name': name}
120 if bookmark not in self.bookmarks:
121 self.bookmarks.append(bookmark)
123 def remove_bookmark(self, path, name):
124 """Remove a bookmark"""
125 bookmark = {'path': display.normalize_path(path), 'name': name}
126 try:
127 self.bookmarks.remove(bookmark)
128 except ValueError:
129 pass
131 def rename_bookmark(self, path, name, new_name):
132 return rename_entry(self.bookmarks, path, name, new_name)
134 def add_recent(self, path, max_recent):
135 normalize = display.normalize_path
136 path = normalize(path)
137 try:
138 index = [normalize(recent['path']) for recent in self.recent].index(path)
139 entry = self.recent.pop(index)
140 except (IndexError, ValueError):
141 entry = {
142 'name': os.path.basename(path),
143 'path': path,
145 self.recent.insert(0, entry)
146 if len(self.recent) > max_recent:
147 self.recent.pop()
149 def remove_recent(self, path):
150 """Removes an item from the recent items list"""
151 normalize = display.normalize_path
152 path = normalize(path)
153 try:
154 index = [normalize(recent.get('path', '')) for recent in self.recent].index(
155 path
157 except ValueError:
158 return
159 try:
160 self.recent.pop(index)
161 except IndexError:
162 return
164 def rename_recent(self, path, name, new_name):
165 return rename_entry(self.recent, path, name, new_name)
167 def path(self):
168 return self.config_path
170 def save(self, sync=True):
171 """Write settings robustly to avoid losing data during a forced shutdown.
173 To save robustly we take these steps:
174 * Write the new settings to a .tmp file.
175 * Rename the current settings to a .bak file.
176 * Rename the new settings from .tmp to the settings file.
177 * Flush the data to disk
178 * Delete the .bak file.
180 Cf. https://github.com/git-cola/git-cola/issues/1241
182 path = self.path()
183 path_tmp = path + '.tmp'
184 path_bak = path + '.bak'
185 # Write the new settings to the .tmp file.
186 if not write_json(self.values, path_tmp, sync=sync):
187 return
188 # Rename the current settings to a .bak file.
189 if core.exists(path) and not rename_path(path, path_bak):
190 return
191 # Rename the new settings from .tmp to the settings file.
192 if not rename_path(path_tmp, path):
193 return
194 # Delete the .bak file.
195 if core.exists(path_bak):
196 remove_path(path_bak)
198 def load(self, path=None):
199 """Load settings robustly.
201 Attempt to load settings from the .bak file if it exists since it indicates
202 that the program terminated before the data was flushed to disk. This can
203 happen when a machine is force-shutdown, for example.
205 This follows the strategy outlined in issue #1241. If the .bak file exists
206 we use it, otherwise we fallback to the actual path or the .tmp path as a
207 final last-ditch attempt to recover settings.
210 path = self.path()
211 path_bak = path + '.bak'
212 path_tmp = path + '.tmp'
214 if core.exists(path_bak):
215 self.values.update(self.asdict(path=path_bak))
216 elif core.exists(path):
217 self.values.update(self.asdict(path=path))
218 elif core.exists(path_tmp):
219 # This is potentially dangerous, but it's guarded by the fact that the
220 # file must be valid JSON in order otherwise the reader will return an
221 # empty string, thus making this a no-op.
222 self.values.update(self.asdict(path=path_tmp))
223 else:
224 # This is either a new installation or the settings were lost.
225 pass
226 # We could try to remove the .bak and .tmp files, but it's better to set save()
227 # handle that the next time it succeeds.
228 self.upgrade_settings()
229 return True
231 @staticmethod
232 def read(verify=git.is_git_worktree):
233 """Load settings from disk"""
234 settings = Settings(verify=verify)
235 settings.load()
236 return settings
238 def upgrade_settings(self):
239 """Upgrade git-cola settings"""
240 # Upgrade bookmarks to the new dict-based bookmarks format.
241 normalize = display.normalize_path
242 if self.bookmarks and not isinstance(self.bookmarks[0], dict):
243 bookmarks = [
244 {'name': os.path.basename(path), 'path': normalize(path)}
245 for path in self.bookmarks
247 self.values['bookmarks'] = bookmarks
249 if self.recent and not isinstance(self.recent[0], dict):
250 recent = [
251 {'name': os.path.basename(path), 'path': normalize(path)}
252 for path in self.recent
254 self.values['recent'] = recent
256 def asdict(self, path=None):
257 if not path:
258 path = self.path()
259 if core.exists(path):
260 return read_json(path)
261 # We couldn't find ~/.config/git-cola, try ~/.cola
262 values = {}
263 path = os.path.join(core.expanduser('~'), '.cola')
264 if core.exists(path):
265 json_values = read_json(path)
266 for key in self.values:
267 try:
268 values[key] = json_values[key]
269 except KeyError:
270 pass
271 # Ensure that all stored bookmarks use normalized paths ("/" only).
272 normalize = display.normalize_path
273 for entry in values.get('bookmarks', []):
274 entry['path'] = normalize(entry['path'])
275 for entry in values.get('recent', []):
276 entry['path'] = normalize(entry['path'])
277 return values
279 def save_gui_state(self, gui, sync=True):
280 """Saves settings for a widget"""
281 name = gui.name()
282 self.gui_state[name] = mkdict(gui.export_state())
283 self.save(sync=sync)
285 def get_gui_state(self, gui):
286 """Returns the saved state for a tool"""
287 return self.get(gui.name())
289 def get(self, gui_name):
290 """Returns the saved state for a tool by name"""
291 try:
292 state = mkdict(self.gui_state[gui_name])
293 except KeyError:
294 state = self.gui_state[gui_name] = {}
295 return state
297 def get_value(self, name, key, default=None):
298 """Return a specific setting value for the specified tool and setting key"""
299 return self.get(name).get(key, default)
301 def set_value(self, name, key, value, save=True, sync=True):
302 """Store a specific setting value for the specified tool and setting key value"""
303 values = self.get(name)
304 values[key] = value
305 if save:
306 self.save(sync=sync)
309 def rename_entry(entries, path, name, new_name):
310 normalize = display.normalize_path
311 path = normalize(path)
312 entry = {'name': name, 'path': path}
313 try:
314 index = entries.index(entry)
315 except ValueError:
316 return False
318 if all(item['name'] != new_name for item in entries):
319 entries[index]['name'] = new_name
320 result = True
321 else:
322 result = False
323 return result
326 class Session(Settings):
327 """Store per-session settings
329 XDG sessions are created by the QApplication::commitData() callback.
330 These sessions are stored once, and loaded once. They are deleted once
331 loaded. The behavior of path() is such that it forgets its session path()
332 and behaves like a return Settings object after the session has been
333 loaded once.
335 Once the session is loaded, it is removed and further calls to save()
336 will save to the usual $XDG_CONFIG_HOME/git-cola/settings location.
340 _sessions_dir = resources.config_home('sessions')
342 repo = property(lambda self: self.values['local'])
344 def __init__(self, session_id, repo=None):
345 Settings.__init__(self)
346 self.session_id = session_id
347 self.values.update({'local': repo})
348 self.expired = False
350 def session_path(self):
351 """The session-specific session file"""
352 return os.path.join(self._sessions_dir, self.session_id)
354 def path(self):
355 base_path = super().path()
356 if self.expired:
357 path = base_path
358 else:
359 path = self.session_path()
360 if not os.path.exists(path):
361 path = base_path
362 return path
364 def load(self, path=None):
365 """Load the session and expire it for future loads
367 The session should be loaded only once. We remove the session file
368 when it's loaded, and set the session to be expired. This results in
369 future calls to load() and save() using the default Settings path
370 rather than the session-specific path.
372 The use case for sessions is when the user logs out with apps running.
373 We will restore their state, and if they then shutdown, it'll be just
374 like a normal shutdown and settings will be stored to
375 ~/.config/git-cola/settings instead of the session path.
377 This is accomplished by "expiring" the session after it has
378 been loaded initially.
381 result = super().load(path=path)
382 # This is the initial load, so expire the session and remove the
383 # session state file. Future calls will be equivalent to
384 # Settings.load().
385 if not self.expired:
386 self.expired = True
387 path = self.session_path()
388 if core.exists(path):
389 try:
390 os.unlink(path)
391 except (OSError, ValueError):
392 pass
393 return True
394 return False
396 return result
398 def update(self):
399 """Reload settings from the base settings path"""
400 # This method does not expire the session.
401 path = super().path()
402 return super().load(path=path)