core: invasive pylint updates
[git-cola.git] / cola / app.py
blob4ca6a106b8ba3837361459ed1b2a340cd4146ffd
1 """Provides the main() routine and ColaApplication"""
2 from __future__ import division, absolute_import, unicode_literals
3 from functools import partial
4 import argparse
5 import os
6 import signal
7 import sys
8 import time
10 __copyright__ = """
11 Copyright (C) 2007-2017 David Aguilar and contributors
12 """
14 try:
15 from qtpy import QtCore
16 except ImportError:
17 sys.stderr.write("""
18 You do not seem to have PyQt5, PySide, or PyQt4 installed.
19 Please install it before using git-cola, e.g. on a Debian/Ubutnu system:
21 sudo apt-get install python-pyqt5 python-pyqt5.qtwebkit
23 """)
24 sys.exit(1)
26 from qtpy import QtGui
27 from qtpy import QtWidgets
28 from qtpy.QtCore import Qt
30 # Import cola modules
31 from .i18n import N_
32 from .interaction import Interaction
33 from .models import main
34 from .models import selection
35 from .widgets import cfgactions
36 from .widgets import defs
37 from .widgets import standard
38 from .widgets import startup
39 from .settings import Session
40 from . import cmds
41 from . import core
42 from . import compat
43 from . import fsmonitor
44 from . import git
45 from . import gitcfg
46 from . import guicmds
47 from . import icons
48 from . import i18n
49 from . import qtcompat
50 from . import qtutils
51 from . import resources
52 from . import utils
53 from . import version
56 def setup_environment():
57 """Set environment variables to control git's behavior"""
58 # Allow Ctrl-C to exit
59 signal.signal(signal.SIGINT, signal.SIG_DFL)
61 # Session management wants an absolute path when restarting
62 sys.argv[0] = sys_argv0 = os.path.abspath(sys.argv[0])
64 # Spoof an X11 display for SSH
65 os.environ.setdefault('DISPLAY', ':0')
67 if not core.getenv('SHELL', ''):
68 for shell in ('/bin/zsh', '/bin/bash', '/bin/sh'):
69 if os.path.exists(shell):
70 compat.setenv('SHELL', shell)
71 break
73 # Setup the path so that git finds us when we run 'git cola'
74 path_entries = core.getenv('PATH', '').split(os.pathsep)
75 bindir = core.decode(os.path.dirname(sys_argv0))
76 path_entries.append(bindir)
77 path = os.pathsep.join(path_entries)
78 compat.setenv('PATH', path)
80 # We don't ever want a pager
81 compat.setenv('GIT_PAGER', '')
83 # Setup *SSH_ASKPASS
84 git_askpass = core.getenv('GIT_ASKPASS')
85 ssh_askpass = core.getenv('SSH_ASKPASS')
86 if git_askpass:
87 askpass = git_askpass
88 elif ssh_askpass:
89 askpass = ssh_askpass
90 elif sys.platform == 'darwin':
91 askpass = resources.share('bin', 'ssh-askpass-darwin')
92 else:
93 askpass = resources.share('bin', 'ssh-askpass')
95 compat.setenv('GIT_ASKPASS', askpass)
96 compat.setenv('SSH_ASKPASS', askpass)
98 # --- >8 --- >8 ---
99 # Git v1.7.10 Release Notes
100 # =========================
102 # Compatibility Notes
103 # -------------------
105 # * From this release on, the "git merge" command in an interactive
106 # session will start an editor when it automatically resolves the
107 # merge for the user to explain the resulting commit, just like the
108 # "git commit" command does when it wasn't given a commit message.
110 # If you have a script that runs "git merge" and keeps its standard
111 # input and output attached to the user's terminal, and if you do not
112 # want the user to explain the resulting merge commits, you can
113 # export GIT_MERGE_AUTOEDIT environment variable set to "no", like
114 # this:
116 # #!/bin/sh
117 # GIT_MERGE_AUTOEDIT=no
118 # export GIT_MERGE_AUTOEDIT
120 # to disable this behavior (if you want your users to explain their
121 # merge commits, you do not have to do anything). Alternatively, you
122 # can give the "--no-edit" option to individual invocations of the
123 # "git merge" command if you know everybody who uses your script has
124 # Git v1.7.8 or newer.
125 # --- >8 --- >8 ---
126 # Longer-term: Use `git merge --no-commit` so that we always
127 # have a chance to explain our merges.
128 compat.setenv('GIT_MERGE_AUTOEDIT', 'no')
131 def get_icon_themes(context):
132 """Return the default icon theme names"""
133 themes = []
135 icon_themes_env = core.getenv('GIT_COLA_ICON_THEME')
136 if icon_themes_env:
137 themes.extend([x for x in icon_themes_env.split(':') if x])
139 icon_themes_cfg = context.cfg.get_all('cola.icontheme')
140 if icon_themes_cfg:
141 themes.extend(icon_themes_cfg)
143 if not themes:
144 themes.append('light')
146 return themes
149 # style note: we use camelCase here since we're masquerading a Qt class
150 class ColaApplication(object):
151 """The main cola application
153 ColaApplication handles i18n of user-visible data
156 def __init__(self, context, argv, locale=None, icon_themes=None):
157 cfgactions.install()
158 i18n.install(locale)
159 qtcompat.install()
160 guicmds.install()
161 standard.install()
162 icons.install(icon_themes or get_icon_themes(context))
164 self.context = context
165 self._app = ColaQApplication(context, list(argv))
166 self._app.setWindowIcon(icons.cola())
167 self._install_style()
169 def _install_style(self):
170 """Generate and apply a stylesheet to the app"""
171 palette = self._app.palette()
172 window = palette.color(QtGui.QPalette.Window)
173 highlight = palette.color(QtGui.QPalette.Highlight)
174 shadow = palette.color(QtGui.QPalette.Shadow)
175 base = palette.color(QtGui.QPalette.Base)
177 window_rgb = qtutils.rgb_css(window)
178 highlight_rgb = qtutils.rgb_css(highlight)
179 shadow_rgb = qtutils.rgb_css(shadow)
180 base_rgb = qtutils.rgb_css(base)
182 self._app.setStyleSheet("""
183 QCheckBox::indicator {
184 width: %(checkbox_size)spx;
185 height: %(checkbox_size)spx;
187 QCheckBox::indicator::unchecked {
188 border: %(checkbox_border)spx solid %(shadow_rgb)s;
189 background: %(base_rgb)s;
191 QCheckBox::indicator::checked {
192 image: url(%(checkbox_icon)s);
193 border: %(checkbox_border)spx solid %(shadow_rgb)s;
194 background: %(base_rgb)s;
197 QRadioButton::indicator {
198 width: %(radio_size)spx;
199 height: %(radio_size)spx;
201 QRadioButton::indicator::unchecked {
202 border: %(radio_border)spx solid %(shadow_rgb)s;
203 border-radius: %(radio_radius)spx;
204 background: %(base_rgb)s;
206 QRadioButton::indicator::checked {
207 image: url(%(radio_icon)s);
208 border: %(radio_border)spx solid %(shadow_rgb)s;
209 border-radius: %(radio_radius)spx;
210 background: %(base_rgb)s;
213 QSplitter::handle:hover {
214 background: %(highlight_rgb)s;
217 QMainWindow::separator {
218 background: %(window_rgb)s;
219 width: %(separator)spx;
220 height: %(separator)spx;
222 QMainWindow::separator:hover {
223 background: %(highlight_rgb)s;
226 """ % dict(separator=defs.separator,
227 window_rgb=window_rgb,
228 highlight_rgb=highlight_rgb,
229 shadow_rgb=shadow_rgb,
230 base_rgb=base_rgb,
231 checkbox_border=defs.border,
232 checkbox_icon=icons.check_name(),
233 checkbox_size=defs.checkbox,
234 radio_border=defs.radio_border,
235 radio_icon=icons.dot_name(),
236 radio_radius=defs.checkbox//2,
237 radio_size=defs.checkbox))
239 def activeWindow(self):
240 """QApplication::activeWindow() pass-through"""
241 return self._app.activeWindow()
243 def desktop(self):
244 """QApplication::desktop() pass-through"""
245 return self._app.desktop()
247 def start(self):
248 """Wrap exec_() and start the application"""
249 # Defer connection so that local cola.inotify is honored
250 context = self.context
251 monitor = context.fsmonitor
252 monitor.files_changed.connect(
253 cmds.run(cmds.Refresh, context), type=Qt.QueuedConnection)
254 monitor.config_changed.connect(
255 cmds.run(cmds.RefreshConfig, context), type=Qt.QueuedConnection)
256 # Start the filesystem monitor thread
257 monitor.start()
258 return self._app.exec_()
260 def stop(self):
261 """Finalize the application"""
262 self.context.fsmonitor.stop()
263 # Workaround QTBUG-52988 by deleting the app manually to prevent a
264 # crash during app shutdown.
265 # https://bugreports.qt.io/browse/QTBUG-52988
266 try:
267 del self._app
268 except (AttributeError, RuntimeError):
269 pass
270 self._app = None
272 def exit(self, status):
273 """QApplication::exit(status) pass-through"""
274 return self._app.exit(status)
277 class ColaQApplication(QtWidgets.QApplication):
278 """QApplication implementation for handling custom events"""
280 def __init__(self, context, argv):
281 super(ColaQApplication, self).__init__(argv)
282 self.context = context
284 def event(self, e):
285 """Respond to focus events for the cola.refreshonfocus feature"""
286 if e.type() == QtCore.QEvent.ApplicationActivate:
287 context = self.context
288 if context:
289 cfg = context.cfg
290 if (context.git.is_valid()
291 and cfg.get('cola.refreshonfocus', default=False)):
292 cmds.do(cmds.Refresh, context)
293 return super(ColaQApplication, self).event(e)
295 def commitData(self, session_mgr):
296 """Save session data"""
297 if not self.context or not self.context.view:
298 return
299 view = self.context.view
300 if not hasattr(view, 'save_state'):
301 return
302 sid = session_mgr.sessionId()
303 skey = session_mgr.sessionKey()
304 session_id = '%s_%s' % (sid, skey)
305 session = Session(session_id, repo=core.getcwd())
306 view.save_state(settings=session)
309 def process_args(args):
310 """Process and verify command-line arguments"""
311 if args.version:
312 # Accept 'git cola --version' or 'git cola version'
313 version.print_version()
314 sys.exit(core.EXIT_SUCCESS)
316 # Handle session management
317 restore_session(args)
319 # Bail out if --repo is not a directory
320 repo = core.decode(args.repo)
321 if repo.startswith('file:'):
322 repo = repo[len('file:'):]
323 repo = core.realpath(repo)
324 if not core.isdir(repo):
325 errmsg = N_('fatal: "%s" is not a directory. '
326 'Please specify a correct --repo <path>.') % repo
327 core.print_stderr(errmsg)
328 sys.exit(core.EXIT_USAGE)
331 def restore_session(args):
332 """Load a session based on the window-manager provided arguments"""
333 # args.settings is provided when restoring from a session.
334 args.settings = None
335 if args.session is None:
336 return
337 session = Session(args.session)
338 if session.load():
339 args.settings = session
340 args.repo = session.repo
343 def application_init(args, update=False):
344 """Parses the command-line arguments and starts git-cola
346 # Ensure that we're working in a valid git repository.
347 # If not, try to find one. When found, chdir there.
348 setup_environment()
349 process_args(args)
351 context = new_context(args)
352 timer = context.timer
353 timer.start('init')
355 new_worktree(context, args.repo, args.prompt, args.settings)
357 if update:
358 context.model.update_status()
360 timer.stop('init')
361 if args.perf:
362 timer.display('init')
363 return context
366 def new_context(args):
367 """Create top-level ApplicationContext objects"""
368 context = ApplicationContext(args)
369 context.git = git.create()
370 context.cfg = gitcfg.create(context)
371 context.fsmonitor = fsmonitor.create(context)
372 context.selection = selection.create()
373 context.model = main.create(context)
374 context.app = new_application(context, args)
375 context.timer = Timer()
377 return context
380 def application_run(context, view, start=None, stop=None):
381 """Run the application main loop"""
382 context.set_view(view)
383 view.show()
385 # Startup callbacks
386 if start:
387 start(context, view)
388 # Start the event loop
389 result = context.app.start()
390 # Finish
391 if stop:
392 stop(context, view)
393 context.app.stop()
395 return result
398 def application_start(context, view):
399 """Show the GUI and start the main event loop"""
400 # Store the view for session management
401 return application_run(context, view,
402 start=default_start, stop=default_stop)
405 def default_start(context, _view):
406 """Scan for the first time"""
407 QtCore.QTimer.singleShot(0, startup_message)
408 QtCore.QTimer.singleShot(0, lambda: async_update(context))
411 def default_stop(_context, _view):
412 """All done, cleanup"""
413 QtCore.QThreadPool.globalInstance().waitForDone()
416 def add_common_arguments(parser):
417 """Add command arguments to the ArgumentParser"""
418 # We also accept 'git cola version'
419 parser.add_argument('--version', default=False, action='store_true',
420 help='print version number')
422 # Specifies a git repository to open
423 parser.add_argument('-r', '--repo', metavar='<repo>', default=core.getcwd(),
424 help='open the specified git repository')
426 # Specifies that we should prompt for a repository at startup
427 parser.add_argument('--prompt', action='store_true', default=False,
428 help='prompt for a repository')
430 # Specify the icon theme
431 parser.add_argument('--icon-theme', metavar='<theme>',
432 dest='icon_themes', action='append', default=[],
433 help='specify an icon theme (name or directory)')
435 # Resume an X Session Management session
436 parser.add_argument('-session', metavar='<session>', default=None,
437 help=argparse.SUPPRESS)
439 # Enable timing information
440 parser.add_argument('--perf', action='store_true', default=False,
441 help=argparse.SUPPRESS)
444 def new_application(context, args):
445 """Create a new ColaApplication"""
446 return ColaApplication(context, sys.argv, icon_themes=args.icon_themes)
449 def new_worktree(context, repo, prompt, settings):
450 """Find a Git repository, or prompt for one when not found"""
451 model = context.model
452 cfg = context.cfg
453 parent = qtutils.active_window()
454 valid = False
456 if not prompt:
457 valid = model.set_worktree(repo)
458 if not valid:
459 # We are not currently in a git repository so we need to find one.
460 # Before prompting the user for a repository, check if they've
461 # configured a default repository and attempt to use it.
462 default_repo = cfg.get('cola.defaultrepo')
463 if default_repo:
464 valid = model.set_worktree(default_repo)
466 while not valid:
467 # If we've gotten into this loop then that means that neither the
468 # current directory nor the default repository were available.
469 # Prompt the user for a repository.
470 startup_dlg = startup.StartupDialog(context, parent, settings=settings)
471 gitdir = startup_dlg.find_git_repo()
472 if not gitdir:
473 sys.exit(core.EXIT_NOINPUT)
474 valid = model.set_worktree(gitdir)
477 def async_update(context):
478 """Update the model in the background
480 git-cola should startup as quickly as possible.
483 update_status = partial(context.model.update_status, update_index=True)
484 task = qtutils.SimpleTask(context.view, update_status)
485 context.runtask.start(task)
488 def startup_message():
489 """Print debug startup messages"""
490 trace = git.GIT_COLA_TRACE
491 if trace == '2' or trace == 'trace':
492 msg1 = 'info: debug level 2: trace mode enabled'
493 msg2 = 'info: set GIT_COLA_TRACE=1 for less-verbose output'
494 Interaction.log(msg1)
495 Interaction.log(msg2)
496 elif trace:
497 msg1 = 'info: debug level 1'
498 msg2 = 'info: set GIT_COLA_TRACE=2 for trace mode'
499 Interaction.log(msg1)
500 Interaction.log(msg2)
503 class Timer(object):
504 """Simple performance timer"""
506 def __init__(self):
507 self._data = {}
509 def start(self, key):
510 """Start a timer"""
511 now = time.time()
512 self._data[key] = [now, now]
514 def stop(self, key):
515 """Stop a timer and return its elapsed time"""
516 entry = self._data[key]
517 entry[1] = time.time()
518 return self.elapsed(key)
520 def elapsed(self, key):
521 """Return the elapsed time for a timer"""
522 entry = self._data[key]
523 return entry[1] - entry[0]
525 def display(self, key):
526 """Display a timer"""
527 elapsed = self.elapsed(key)
528 sys.stdout.write('%s: %.5fs\n' % (key, elapsed))
531 class ApplicationContext(object):
532 """Context for performing operations on Git and related data models"""
534 def __init__(self, args):
535 self.args = args
536 self.app = None # ColaApplication
537 self.git = None # git.Git
538 self.cfg = None # gitcfg.GitConfig
539 self.model = None # main.MainModel
540 self.timer = None # Timer
541 self.runtask = None # qtutils.RunTask
542 self.selection = None # selection.SelectionModel
543 self.fsmonitor = None # fsmonitor
544 self.view = None # QWidget
546 def set_view(self, view):
547 """Initialize view-specific members"""
548 self.view = view
549 self.runtask = qtutils.RunTask(parent=view)
552 def winmain(main_fn, *argv):
553 """Find Git and launch main(argv)"""
554 git_path = find_git()
555 if git_path:
556 prepend_path(git_path)
557 return main_fn(*argv)
560 def find_git():
561 """Return the path of git.exe, or None if we can't find it."""
562 if not utils.is_win32():
563 return None # UNIX systems have git in their $PATH
565 # If the user wants to use a Git/bin/ directory from a non-standard
566 # directory then they can write its location into
567 # ~/.config/git-cola/git-bindir
568 git_bindir = os.path.expanduser(os.path.join('~', '.config', 'git-cola',
569 'git-bindir'))
570 if core.exists(git_bindir):
571 custom_path = core.read(git_bindir).strip()
572 if custom_path and core.exists(custom_path):
573 return custom_path
575 # Try to find Git's bin/ directory in one of the typical locations
576 pf = os.environ.get('ProgramFiles', 'C:\\Program Files')
577 pf32 = os.environ.get('ProgramFiles(x86)', 'C:\\Program Files (x86)')
578 for p in [pf32, pf, 'C:\\']:
579 candidate = os.path.join(p, 'Git\\bin')
580 if os.path.isdir(candidate):
581 return candidate
583 return None
586 def prepend_path(path):
587 """Adds git to the PATH. This is needed on Windows."""
588 path = core.decode(path)
589 path_entries = core.getenv('PATH', '').split(os.pathsep)
590 if path not in path_entries:
591 path_entries.insert(0, path)
592 compat.setenv('PATH', os.pathsep.join(path_entries))