garden: use 4-space indents
[git-cola.git] / cola / settings.py
blob6ef5cd3576897a4ef3c67909bc3fb3b2dddc2e87
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):
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 except (ValueError, TypeError, OSError):
49 sys.stderr.write('git-cola: error writing "%s"\n' % path)
50 return False
51 return True
54 def rename_path(old, new):
55 """Rename a filename. Catch exceptions and return False on error."""
56 try:
57 core.rename(old, new)
58 except OSError:
59 sys.stderr.write(f'git-cola: error renaming "{old}" to "{new}"\n')
60 return False
61 return True
64 def remove_path(path):
65 """Remove a filename. Report errors to stderr."""
66 try:
67 core.remove(path)
68 except OSError:
69 sys.stderr.write('git-cola: error removing "%s"\n' % path)
72 class Settings:
73 config_path = resources.config_home('settings')
74 bookmarks = property(lambda self: mklist(self.values['bookmarks']))
75 gui_state = property(lambda self: mkdict(self.values['gui_state']))
76 recent = property(lambda self: mklist(self.values['recent']))
77 copy_formats = property(lambda self: mklist(self.values['copy_formats']))
79 def __init__(self, verify=git.is_git_worktree):
80 """Load existing settings if they exist"""
81 self.values = {
82 'bookmarks': [],
83 'gui_state': {},
84 'recent': [],
85 'copy_formats': [],
87 self.verify = verify
89 def remove_missing_bookmarks(self):
90 """Remove "favorites" bookmarks that no longer exist"""
91 missing_bookmarks = []
92 for bookmark in self.bookmarks:
93 if not self.verify(bookmark['path']):
94 missing_bookmarks.append(bookmark)
96 for bookmark in missing_bookmarks:
97 try:
98 self.bookmarks.remove(bookmark)
99 except ValueError:
100 pass
102 def remove_missing_recent(self):
103 """Remove "recent" repositories that no longer exist"""
104 missing_recent = []
105 for recent in self.recent:
106 if not self.verify(recent['path']):
107 missing_recent.append(recent)
109 for recent in missing_recent:
110 try:
111 self.recent.remove(recent)
112 except ValueError:
113 pass
115 def add_bookmark(self, path, name):
116 """Adds a bookmark to the saved settings"""
117 bookmark = {'path': display.normalize_path(path), 'name': name}
118 if bookmark not in self.bookmarks:
119 self.bookmarks.append(bookmark)
121 def remove_bookmark(self, path, name):
122 """Remove a bookmark"""
123 bookmark = {'path': display.normalize_path(path), 'name': name}
124 try:
125 self.bookmarks.remove(bookmark)
126 except ValueError:
127 pass
129 def rename_bookmark(self, path, name, new_name):
130 return rename_entry(self.bookmarks, path, name, new_name)
132 def add_recent(self, path, max_recent):
133 normalize = display.normalize_path
134 path = normalize(path)
135 try:
136 index = [normalize(recent['path']) for recent in self.recent].index(path)
137 entry = self.recent.pop(index)
138 except (IndexError, ValueError):
139 entry = {
140 'name': os.path.basename(path),
141 'path': path,
143 self.recent.insert(0, entry)
144 if len(self.recent) > max_recent:
145 self.recent.pop()
147 def remove_recent(self, path):
148 """Removes an item from the recent items list"""
149 normalize = display.normalize_path
150 path = normalize(path)
151 try:
152 index = [normalize(recent.get('path', '')) for recent in self.recent].index(
153 path
155 except ValueError:
156 return
157 try:
158 self.recent.pop(index)
159 except IndexError:
160 return
162 def rename_recent(self, path, name, new_name):
163 return rename_entry(self.recent, path, name, new_name)
165 def path(self):
166 return self.config_path
168 def save(self):
169 """Write settings robustly to avoid losing data during a forced shutdown.
171 To save robustly we take these steps:
172 * Write the new settings to a .tmp file.
173 * Rename the current settings to a .bak file.
174 * Rename the new settings from .tmp to the settings file.
175 * Flush the data to disk
176 * Delete the .bak file.
178 Cf. https://github.com/git-cola/git-cola/issues/1241
180 path = self.path()
181 path_tmp = path + '.tmp'
182 path_bak = path + '.bak'
183 # Write the new settings to the .tmp file.
184 if not write_json(self.values, path_tmp):
185 return
186 # Rename the current settings to a .bak file.
187 if core.exists(path) and not rename_path(path, path_bak):
188 return
189 # Rename the new settings from .tmp to the settings file.
190 if not rename_path(path_tmp, path):
191 return
192 # Flush the data to disk.
193 core.sync()
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):
280 """Saves settings for a cola view"""
281 name = gui.name()
282 self.gui_state[name] = mkdict(gui.export_state())
283 self.save()
285 def get_gui_state(self, gui):
286 """Returns the saved state for a gui"""
287 return self.get_gui_state_by_name(gui.name())
289 def get_gui_state_by_name(self, gui_name):
290 """Returns the saved state for a gui 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
298 def rename_entry(entries, path, name, new_name):
299 normalize = display.normalize_path
300 path = normalize(path)
301 entry = {'name': name, 'path': path}
302 try:
303 index = entries.index(entry)
304 except ValueError:
305 return False
307 if all(item['name'] != new_name for item in entries):
308 entries[index]['name'] = new_name
309 result = True
310 else:
311 result = False
312 return result
315 class Session(Settings):
316 """Store per-session settings
318 XDG sessions are created by the QApplication::commitData() callback.
319 These sessions are stored once, and loaded once. They are deleted once
320 loaded. The behavior of path() is such that it forgets its session path()
321 and behaves like a return Settings object after the session has been
322 loaded once.
324 Once the session is loaded, it is removed and further calls to save()
325 will save to the usual $XDG_CONFIG_HOME/git-cola/settings location.
329 _sessions_dir = resources.config_home('sessions')
331 repo = property(lambda self: self.values['local'])
333 def __init__(self, session_id, repo=None):
334 Settings.__init__(self)
335 self.session_id = session_id
336 self.values.update({'local': repo})
337 self.expired = False
339 def session_path(self):
340 """The session-specific session file"""
341 return os.path.join(self._sessions_dir, self.session_id)
343 def path(self):
344 base_path = super().path()
345 if self.expired:
346 path = base_path
347 else:
348 path = self.session_path()
349 if not os.path.exists(path):
350 path = base_path
351 return path
353 def load(self, path=None):
354 """Load the session and expire it for future loads
356 The session should be loaded only once. We remove the session file
357 when it's loaded, and set the session to be expired. This results in
358 future calls to load() and save() using the default Settings path
359 rather than the session-specific path.
361 The use case for sessions is when the user logs out with apps running.
362 We will restore their state, and if they then shutdown, it'll be just
363 like a normal shutdown and settings will be stored to
364 ~/.config/git-cola/settings instead of the session path.
366 This is accomplished by "expiring" the session after it has
367 been loaded initially.
370 result = super().load(path=path)
371 # This is the initial load, so expire the session and remove the
372 # session state file. Future calls will be equivalent to
373 # Settings.load().
374 if not self.expired:
375 self.expired = True
376 path = self.session_path()
377 if core.exists(path):
378 try:
379 os.unlink(path)
380 except (OSError, ValueError):
381 pass
382 return True
383 return False
385 return result
387 def update(self):
388 """Reload settings from the base settings path"""
389 # This method does not expire the session.
390 path = super().path()
391 return super().load(path=path)