fsmonitor: silence pylint warnings
[git-cola.git] / cola / settings.py
blobdb1494c0f42bc4ed0b39cb811821c877f96cc2a6
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 try:
288 state = mkdict(self.gui_state[gui.name()])
289 except KeyError:
290 state = self.gui_state[gui.name()] = {}
291 return state
294 def rename_entry(entries, path, name, new_name):
295 normalize = display.normalize_path
296 path = normalize(path)
297 entry = {'name': name, 'path': path}
298 try:
299 index = entries.index(entry)
300 except ValueError:
301 return False
303 if all(item['name'] != new_name for item in entries):
304 entries[index]['name'] = new_name
305 result = True
306 else:
307 result = False
308 return result
311 class Session(Settings):
312 """Store per-session settings
314 XDG sessions are created by the QApplication::commitData() callback.
315 These sessions are stored once, and loaded once. They are deleted once
316 loaded. The behavior of path() is such that it forgets its session path()
317 and behaves like a return Settings object after the session has been
318 loaded once.
320 Once the session is loaded, it is removed and further calls to save()
321 will save to the usual $XDG_CONFIG_HOME/git-cola/settings location.
325 _sessions_dir = resources.config_home('sessions')
327 repo = property(lambda self: self.values['local'])
329 def __init__(self, session_id, repo=None):
330 Settings.__init__(self)
331 self.session_id = session_id
332 self.values.update({'local': repo})
333 self.expired = False
335 def session_path(self):
336 """The session-specific session file"""
337 return os.path.join(self._sessions_dir, self.session_id)
339 def path(self):
340 base_path = super().path()
341 if self.expired:
342 path = base_path
343 else:
344 path = self.session_path()
345 if not os.path.exists(path):
346 path = base_path
347 return path
349 def load(self, path=None):
350 """Load the session and expire it for future loads
352 The session should be loaded only once. We remove the session file
353 when it's loaded, and set the session to be expired. This results in
354 future calls to load() and save() using the default Settings path
355 rather than the session-specific path.
357 The use case for sessions is when the user logs out with apps running.
358 We will restore their state, and if they then shutdown, it'll be just
359 like a normal shutdown and settings will be stored to
360 ~/.config/git-cola/settings instead of the session path.
362 This is accomplished by "expiring" the session after it has
363 been loaded initially.
366 result = super().load(path=path)
367 # This is the initial load, so expire the session and remove the
368 # session state file. Future calls will be equivalent to
369 # Settings.load().
370 if not self.expired:
371 self.expired = True
372 path = self.session_path()
373 if core.exists(path):
374 try:
375 os.unlink(path)
376 except (OSError, ValueError):
377 pass
378 return True
379 return False
381 return result
383 def update(self):
384 """Reload settings from the base settings path"""
385 # This method does not expire the session.
386 path = super().path()
387 return super().load(path=path)