prefs: apply flake8 suggestions
[git-cola.git] / cola / gitcfg.py
blobec233552e33dfaef3118d6ec39a9699ca86a3394
1 from __future__ import division, absolute_import, unicode_literals
3 import copy
4 import fnmatch
5 import os
6 import re
7 import struct
8 from binascii import unhexlify
9 from os.path import join
11 from cola import core
12 from cola import git
13 from cola import observable
14 from cola.compat import int_types
15 from cola.decorators import memoize
16 from cola.git import STDOUT
17 from cola.compat import ustr
19 BUILTIN_READER = os.environ.get('GIT_COLA_BUILTIN_CONFIG_READER', False)
21 _USER_CONFIG = core.expanduser(join('~', '.gitconfig'))
22 _USER_XDG_CONFIG = core.expanduser(
23 join(core.getenv('XDG_CONFIG_HOME', join('~', '.config')),
24 'git', 'config'))
27 @memoize
28 def current():
29 """Return the GitConfig singleton."""
30 return GitConfig()
33 def _stat_info():
34 # Try /etc/gitconfig as a fallback for the system config
35 paths = [('system', '/etc/gitconfig'),
36 ('user', _USER_XDG_CONFIG),
37 ('user', _USER_CONFIG)]
38 config = git.current().git_path('config')
39 if config:
40 paths.append(('repo', config))
42 statinfo = []
43 for category, path in paths:
44 try:
45 statinfo.append((category, path, core.stat(path).st_mtime))
46 except OSError:
47 continue
48 return statinfo
51 def _cache_key():
52 # Try /etc/gitconfig as a fallback for the system config
53 paths = ['/etc/gitconfig',
54 _USER_XDG_CONFIG,
55 _USER_CONFIG]
56 config = git.current().git_path('config')
57 if config:
58 paths.append(config)
60 mtimes = []
61 for path in paths:
62 try:
63 mtimes.append(core.stat(path).st_mtime)
64 except OSError:
65 continue
66 return mtimes
69 def _config_to_python(v):
70 """Convert a Git config string into a Python value"""
72 if v in ('true', 'yes'):
73 v = True
74 elif v in ('false', 'no'):
75 v = False
76 else:
77 try:
78 v = int(v)
79 except ValueError:
80 pass
81 return v
84 def _config_key_value(line, splitchar):
85 """Split a config line into a (key, value) pair"""
87 try:
88 k, v = line.split(splitchar, 1)
89 except ValueError:
90 # the user has a emptyentry in their git config,
91 # which Git interprets as meaning "true"
92 k = line
93 v = 'true'
94 return k, _config_to_python(v)
97 class GitConfig(observable.Observable):
98 """Encapsulate access to git-config values."""
100 message_user_config_changed = 'user_config_changed'
101 message_repo_config_changed = 'repo_config_changed'
103 def __init__(self):
104 observable.Observable.__init__(self)
105 self.git = git.current()
106 self._map = {}
107 self._system = {}
108 self._user = {}
109 self._user_or_system = {}
110 self._repo = {}
111 self._all = {}
112 self._cache_key = None
113 self._configs = []
114 self._config_files = {}
115 self._value_cache = {}
116 self._attr_cache = {}
117 self._find_config_files()
119 def reset(self):
120 self._map.clear()
121 self._system.clear()
122 self._user.clear()
123 self._user_or_system.clear()
124 self._repo.clear()
125 self._all.clear()
126 self._cache_key = None
127 self._configs = []
128 self._config_files.clear()
129 self._value_cache = {}
130 self._attr_cache = {}
131 self._find_config_files()
133 def user(self):
134 return copy.deepcopy(self._user)
136 def repo(self):
137 return copy.deepcopy(self._repo)
139 def all(self):
140 return copy.deepcopy(self._all)
142 def _find_config_files(self):
144 Classify git config files into 'system', 'user', and 'repo'.
146 Populates self._configs with a list of the files in
147 reverse-precedence order. self._config_files is populated with
148 {category: path} where category is one of 'system', 'user', or 'repo'.
151 # Try the git config in git's installation prefix
152 statinfo = _stat_info()
153 self._configs = [x[1] for x in statinfo]
154 self._config_files = {}
155 for (cat, path, mtime) in statinfo:
156 self._config_files[cat] = path
158 def update(self):
159 """Read config values from git."""
160 if self._cached():
161 return
162 self._read_configs()
164 def _cached(self):
166 Return True when the cache matches.
168 Updates the cache and returns False when the cache does not match.
171 cache_key = _cache_key()
172 if self._cache_key is None or cache_key != self._cache_key:
173 self._cache_key = cache_key
174 return False
175 return True
177 def _read_configs(self):
178 """Read git config value into the system, user and repo dicts."""
179 self._map.clear()
180 self._system.clear()
181 self._user.clear()
182 self._user_or_system.clear()
183 self._repo.clear()
184 self._all.clear()
186 if 'system' in self._config_files:
187 self._system.update(
188 self.read_config(self._config_files['system']))
190 if 'user' in self._config_files:
191 self._user.update(
192 self.read_config(self._config_files['user']))
194 if 'repo' in self._config_files:
195 self._repo.update(
196 self.read_config(self._config_files['repo']))
198 for dct in (self._system, self._user):
199 self._user_or_system.update(dct)
201 for dct in (self._system, self._user, self._repo):
202 self._all.update(dct)
204 def read_config(self, path):
205 """Return git config data from a path as a dictionary."""
207 if BUILTIN_READER:
208 return self._read_config_file(path)
210 dest = {}
211 args = ('--null', '--file', path, '--list')
212 config_lines = self.git.config(*args)[STDOUT].split('\0')
213 for line in config_lines:
214 if not line:
215 # the user has an invalid entry in their git config
216 continue
217 k, v = _config_key_value(line, '\n')
218 self._map[k.lower()] = k
219 dest[k] = v
220 return dest
222 def _read_config_file(self, path):
223 """Read a .gitconfig file into a dict"""
225 config = {}
226 header_simple = re.compile(r'^\[(\s+)]$')
227 header_subkey = re.compile(r'^\[(\s+) "(\s+)"\]$')
229 with core.xopen(path, 'rt') as f:
230 file_lines = f.readlines()
232 stripped_lines = [line.strip() for line in file_lines]
233 lines = [line for line in stripped_lines if bool(line)]
234 prefix = ''
235 for line in lines:
236 if line.startswith('#'):
237 continue
239 match = header_simple.match(line)
240 if match:
241 prefix = match.group(1) + '.'
242 continue
243 match = header_subkey.match(line)
244 if match:
245 prefix = match.group(1) + '.' + match.group(2) + '.'
246 continue
248 k, v = _config_key_value(line, '=')
249 k = prefix + k
250 self._map[k.lower()] = k
251 config[k] = v
253 return config
255 def _get(self, src, key, default):
256 self.update()
257 try:
258 value = self._get_with_fallback(src, key)
259 except KeyError:
260 value = default
261 return value
263 def _get_with_fallback(self, src, key):
264 try:
265 return src[key]
266 except KeyError:
267 pass
268 key = self._map.get(key.lower(), key)
269 try:
270 return src[key]
271 except KeyError:
272 pass
273 # Allow the final KeyError to bubble up
274 return src[key.lower()]
276 def get(self, key, default=None):
277 """Return the string value for a config key."""
278 return self._get(self._all, key, default)
280 def get_user(self, key, default=None):
281 return self._get(self._user, key, default)
283 def get_repo(self, key, default=None):
284 return self._get(self._repo, key, default)
286 def get_user_or_system(self, key, default=None):
287 return self._get(self._user_or_system, key, default)
289 def python_to_git(self, value):
290 if type(value) is bool:
291 if value:
292 return 'true'
293 else:
294 return 'false'
295 if isinstance(value, int_types):
296 return ustr(value)
297 return value
299 def set_user(self, key, value):
300 if value is None:
301 self.unset_user(key)
302 return
303 self.git.config('--global', key, self.python_to_git(value))
304 self.update()
305 msg = self.message_user_config_changed
306 self.notify_observers(msg, key, value)
308 def set_repo(self, key, value):
309 self.git.config(key, self.python_to_git(value))
310 self.update()
311 msg = self.message_repo_config_changed
312 self.notify_observers(msg, key, value)
314 def unset_user(self, key):
315 self.git.config('--global', '--unset', key)
316 self.update()
317 msg = self.message_repo_config_changed
318 self.notify_observers(msg, key, None)
320 def find(self, pat):
321 pat = pat.lower()
322 match = fnmatch.fnmatch
323 result = {}
324 self.update()
325 for key, val in self._all.items():
326 if match(key.lower(), pat):
327 result[key] = val
328 return result
330 def get_cached(self, key, default=None):
331 cache = self._value_cache
332 try:
333 value = cache[key]
334 except KeyError:
335 value = cache[key] = self.get(key, default=default)
336 return value
338 def gui_encoding(self):
339 return self.get_cached('gui.encoding', default='utf-8')
341 def is_per_file_attrs_enabled(self):
342 return self.get_cached('cola.fileattributes', default=False)
344 def file_encoding(self, path):
345 if not self.is_per_file_attrs_enabled():
346 return self.gui_encoding()
347 cache = self._attr_cache
348 try:
349 value = cache[path]
350 except KeyError:
351 value = cache[path] = (self._file_encoding(path) or
352 self.gui_encoding())
353 return value
355 def _file_encoding(self, path):
356 """Return the file encoding for a path"""
357 status, out, err = self.git.check_attr('encoding', '--', path)
358 if status != 0:
359 return None
360 header = '%s: encoding: ' % path
361 if out.startswith(header):
362 encoding = out[len(header):].strip()
363 if (encoding != 'unspecified' and
364 encoding != 'unset' and
365 encoding != 'set'):
366 return encoding
367 return None
369 def get_guitool_opts(self, name):
370 """Return the guitool.<name> namespace as a dict
372 The dict keys are simplified so that "guitool.$name.cmd" is accessible
373 as `opts[cmd]`.
376 prefix = len('guitool.%s.' % name)
377 guitools = self.find('guitool.%s.*' % name)
378 return dict([(key[prefix:], value)
379 for (key, value) in guitools.items()])
381 def get_guitool_names(self):
382 guitools = self.find('guitool.*.cmd')
383 prefix = len('guitool.')
384 suffix = len('.cmd')
385 return sorted([name[prefix:-suffix]
386 for (name, cmd) in guitools.items()])
388 def get_guitool_names_and_shortcuts(self):
389 """Return guitool names and their configured shortcut"""
390 names = self.get_guitool_names()
391 return [(name, self.get('guitool.%s.shortcut' % name))
392 for name in names]
394 def terminal(self):
395 term = self.get('cola.terminal', None)
396 if not term:
397 # find a suitable default terminal
398 term = 'xterm -e' # for mac osx
399 candidates = ('xfce4-terminal', 'konsole', 'gnome-terminal')
400 for basename in candidates:
401 if core.exists('/usr/bin/%s' % basename):
402 term = '%s -e' % basename
403 break
404 return term
406 def color(self, key, default):
407 string = self.get('cola.color.%s' % key, default)
408 string = core.encode(string)
409 default = core.encode(default)
410 struct_layout = core.encode('BBB')
411 try:
412 r, g, b = struct.unpack(struct_layout, unhexlify(string))
413 except Exception:
414 r, g, b = struct.unpack(struct_layout, unhexlify(default))
415 return (r, g, b)