interaction: flush stdout when printing to the console
[git-cola.git] / cola / settings.py
blobdc842ebdf3874d83650bfd0f0c5a6b4dd9a38d76
1 """Save settings, bookmarks, etc.
2 """
3 from __future__ import division, absolute_import, unicode_literals
4 import json
5 import os
6 import sys
8 from . import core
9 from . import display
10 from . import git
11 from . import resources
14 def mkdict(obj):
15 """Transform None and non-dicts into dicts"""
16 if isinstance(obj, dict):
17 value = obj
18 else:
19 value = {}
20 return value
23 def mklist(obj):
24 """Transform None and non-lists into lists"""
25 if isinstance(obj, list):
26 value = obj
27 elif isinstance(obj, tuple):
28 value = list(obj)
29 else:
30 value = []
31 return value
34 def read_json(path):
35 try:
36 with core.xopen(path, 'rt') as fp:
37 return mkdict(json.load(fp))
38 except (ValueError, TypeError, OSError, IOError): # bad path or json
39 return {}
42 def write_json(values, path):
43 try:
44 parent = os.path.dirname(path)
45 if not core.isdir(parent):
46 core.makedirs(parent)
47 with core.xopen(path, 'wt') as fp:
48 json.dump(values, fp, indent=4)
49 except (ValueError, TypeError, OSError, IOError):
50 sys.stderr.write('git-cola: error writing "%s"\n' % path)
53 class Settings(object):
54 config_path = resources.config_home('settings')
55 bookmarks = property(lambda self: mklist(self.values['bookmarks']))
56 gui_state = property(lambda self: mkdict(self.values['gui_state']))
57 recent = property(lambda self: mklist(self.values['recent']))
58 copy_formats = property(lambda self: mklist(self.values['copy_formats']))
60 def __init__(self, verify=git.is_git_worktree):
61 """Load existing settings if they exist"""
62 self.values = {
63 'bookmarks': [],
64 'gui_state': {},
65 'recent': [],
66 'copy_formats': [],
68 self.verify = verify
70 def remove_missing_bookmarks(self):
71 """Remove "favorites" bookmarks that no longer exist"""
72 missing_bookmarks = []
73 for bookmark in self.bookmarks:
74 if not self.verify(bookmark['path']):
75 missing_bookmarks.append(bookmark)
77 for bookmark in missing_bookmarks:
78 try:
79 self.bookmarks.remove(bookmark)
80 except ValueError:
81 pass
83 def remove_missing_recent(self):
84 """Remove "recent" repositories that no longer exist"""
85 missing_recent = []
86 for recent in self.recent:
87 if not self.verify(recent['path']):
88 missing_recent.append(recent)
90 for recent in missing_recent:
91 try:
92 self.recent.remove(recent)
93 except ValueError:
94 pass
96 def add_bookmark(self, path, name):
97 """Adds a bookmark to the saved settings"""
98 bookmark = {'path': display.normalize_path(path), 'name': name}
99 if bookmark not in self.bookmarks:
100 self.bookmarks.append(bookmark)
102 def remove_bookmark(self, path, name):
103 """Remove a bookmark"""
104 bookmark = {'path': display.normalize_path(path), 'name': name}
105 try:
106 self.bookmarks.remove(bookmark)
107 except ValueError:
108 pass
110 def rename_bookmark(self, path, name, new_name):
111 return rename_entry(self.bookmarks, path, name, new_name)
113 def add_recent(self, path, max_recent):
114 normalize = display.normalize_path
115 path = normalize(path)
116 try:
117 index = [
118 normalize(recent['path']) for recent in self.recent
119 ].index(path)
120 entry = self.recent.pop(index)
121 except (IndexError, ValueError):
122 entry = {
123 'name': os.path.basename(path),
124 'path': path,
126 self.recent.insert(0, entry)
127 if len(self.recent) > max_recent:
128 self.recent.pop()
130 def remove_recent(self, path):
131 """Removes an item from the recent items list"""
132 normalize = display.normalize_path
133 path = normalize(path)
134 try:
135 index = [
136 normalize(recent.get('path', ''))
137 for recent in self.recent
138 ].index(path)
139 except ValueError:
140 return
141 try:
142 self.recent.pop(index)
143 except IndexError:
144 return
146 def rename_recent(self, path, name, new_name):
147 return rename_entry(self.recent, path, name, new_name)
149 def path(self):
150 return self.config_path
152 def save(self):
153 write_json(self.values, self.path())
155 def load(self, path=None):
156 self.values.update(self.asdict(path=path))
157 self.upgrade_settings()
158 return True
160 @staticmethod
161 def read(verify=git.is_git_worktree):
162 """Load settings from disk"""
163 settings = Settings(verify=verify)
164 settings.load()
165 return settings
167 def upgrade_settings(self):
168 """Upgrade git-cola settings"""
169 # Upgrade bookmarks to the new dict-based bookmarks format.
170 normalize = display.normalize_path
171 if self.bookmarks and not isinstance(self.bookmarks[0], dict):
172 bookmarks = [
173 dict(name=os.path.basename(path), path=normalize(path))
174 for path in self.bookmarks
176 self.values['bookmarks'] = bookmarks
178 if self.recent and not isinstance(self.recent[0], dict):
179 recent = [
180 dict(name=os.path.basename(path), path=normalize(path))
181 for path in self.recent
183 self.values['recent'] = recent
185 def asdict(self, path=None):
186 if not path:
187 path = self.path()
188 if core.exists(path):
189 return read_json(path)
190 # We couldn't find ~/.config/git-cola, try ~/.cola
191 values = {}
192 path = os.path.join(core.expanduser('~'), '.cola')
193 if core.exists(path):
194 json_values = read_json(path)
195 for key in self.values:
196 try:
197 values[key] = json_values[key]
198 except KeyError:
199 pass
200 # Ensure that all stored bookmarks use normalized paths ("/" only).
201 normalize = display.normalize_path
202 for entry in values.get('bookmarks', []):
203 entry['path'] = normalize(entry['path'])
204 for entry in values.get('recent', []):
205 entry['path'] = normalize(entry['path'])
206 return values
208 def save_gui_state(self, gui):
209 """Saves settings for a cola view"""
210 name = gui.name()
211 self.gui_state[name] = mkdict(gui.export_state())
212 self.save()
214 def get_gui_state(self, gui):
215 """Returns the saved state for a gui"""
216 try:
217 state = mkdict(self.gui_state[gui.name()])
218 except KeyError:
219 state = self.gui_state[gui.name()] = {}
220 return state
223 def rename_entry(entries, path, name, new_name):
224 normalize = display.normalize_path
225 path = normalize(path)
226 entry = {'name': name, 'path': path}
227 try:
228 index = entries.index(entry)
229 except ValueError:
230 return False
232 if all([item['name'] != new_name for item in entries]):
233 entries[index]['name'] = new_name
234 result = True
235 else:
236 result = False
237 return result
240 class Session(Settings):
241 """Store per-session settings
243 XDG sessions are created by the QApplication::commitData() callback.
244 These sessions are stored once, and loaded once. They are deleted once
245 loaded. The behavior of path() is such that it forgets its session path()
246 and behaves like a return Settings object after the session has been
247 loaded once.
249 Once the session is loaded, it is removed and further calls to save()
250 will save to the usual $XDG_CONFIG_HOME/git-cola/settings location.
254 _sessions_dir = resources.config_home('sessions')
256 repo = property(lambda self: self.values['repo'])
258 def __init__(self, session_id, repo=None):
259 Settings.__init__(self)
260 self.session_id = session_id
261 self.values.update({'repo': repo})
262 self.expired = False
264 def session_path(self):
265 """The session-specific session file"""
266 return os.path.join(self._sessions_dir, self.session_id)
268 def path(self):
269 base_path = super(Session, self).path()
270 if self.expired:
271 path = base_path
272 else:
273 path = self.session_path()
274 if not os.path.exists(path):
275 path = base_path
276 return path
278 def load(self, path=None):
279 """Load the session and expire it for future loads
281 The session should be loaded only once. We remove the session file
282 when it's loaded, and set the session to be expired. This results in
283 future calls to load() and save() using the default Settings path
284 rather than the session-specific path.
286 The use case for sessions is when the user logs out with apps running.
287 We will restore their state, and if they then shutdown, it'll be just
288 like a normal shutdown and settings will be stored to
289 ~/.config/git-cola/settings instead of the session path.
291 This is accomplished by "expiring" the session after it has
292 been loaded initially.
295 result = super(Session, self).load(path=path)
296 # This is the initial load, so expire the session and remove the
297 # session state file. Future calls will be equivalent to
298 # Settings.load().
299 if not self.expired:
300 self.expired = True
301 path = self.session_path()
302 if core.exists(path):
303 try:
304 os.unlink(path)
305 except (OSError, ValueError):
306 pass
307 return True
308 return False
310 return result
312 def update(self):
313 """Reload settings from the base settings path"""
314 # This method does not expire the session.
315 path = super(Session, self).path()
316 return super(Session, self).load(path=path)