diff --git a/pubs/config/spec.py b/pubs/config/spec.py index 38e99fd..59a3cc0 100644 --- a/pubs/config/spec.py +++ b/pubs/config/spec.py @@ -96,11 +96,24 @@ active = force_list(default=list('alias')) # description = lists number of pubs in repo [[git]] -# the plugin allows to use `pubs git` and commit changes automatically -# if False, will display git output when invoked +# The git plugin will commit changes to the repository in a git repository +# created at the root of the pubs directory. All detected changes will be +# commited every time a change is made by a pubs command. +# The plugin also propose the `pubs git` subcommand, to directory send git +# command to the pubs repository. Therefore, `pubs git status` is equivalent +# to `git -C status`, with the `-C` flag instructing +# to invoke git as if the current directory was . Note that a +# limitation of the subcommand is that you cannot use git commands with the +# `-c` option (pubs will interpret it first.) + +# if False, will display git output when automatic commit are made. +# Invocation of `pubs git` will always have output displayed. quiet = boolean(default=True) # if True, git will not automatically commit changes manual = boolean(default=False) +# if True, color will be conserved from git output (this add `-c color:always` +# to the git invocation). +force_color = boolean(default=True) [internal] diff --git a/pubs/events.py b/pubs/events.py index b259bcf..e9d40b1 100644 --- a/pubs/events.py +++ b/pubs/events.py @@ -48,27 +48,27 @@ class PaperChangeEvent(Event): # Used by repo.push_paper() class AddEvent(PaperChangeEvent): - _format = "Adds paper {citekey}." + _format = "Added paper {citekey}." # Used by repo.push_doc() class DocAddEvent(PaperChangeEvent): - _format = "Adds document for {citekey}." + _format = "Added document for {citekey}." # Used by repo.remove_paper() class RemoveEvent(PaperChangeEvent): - _format = "Removes paper for {citekey}." + _format = "Removed paper for {citekey}." # Used by repo.remove_doc() class DocRemoveEvent(PaperChangeEvent): - _format = "Removes document for {citekey}." + _format = "Removed document for {citekey}." # Used by commands.tag_cmd.command() class TagEvent(PaperChangeEvent): - _format = "Updates tags for {citekey}." + _format = "Updated tags for {citekey}." # Used by commands.edit_cmd.command() class ModifyEvent(PaperChangeEvent): - _format = "Modifies {file_type} file of {citekey}." + _format = "Modified {file_type} file of {citekey}." def __init__(self, citekey, file_type): super(ModifyEvent, self).__init__(citekey) @@ -80,7 +80,7 @@ class ModifyEvent(PaperChangeEvent): # Used by repo.rename_paper() class RenameEvent(PaperChangeEvent): - _format = "Renames paper {old_citekey} to {citekey}." + _format = "Renamed paper {old_citekey} to {citekey}." def __init__(self, paper, old_citekey): super(RenameEvent, self).__init__(paper.citekey) @@ -93,4 +93,4 @@ class RenameEvent(PaperChangeEvent): # Used by commands.note_cmd.command() class NoteEvent(PaperChangeEvent): - _format = "Modifies note {citekey}." + _format = "Modified note of {citekey}." diff --git a/pubs/plugins.py b/pubs/plugins.py index 4d1ae39..f413da3 100644 --- a/pubs/plugins.py +++ b/pubs/plugins.py @@ -27,6 +27,10 @@ class PapersPlugin(object): else: raise RuntimeError("{} instance not created".format(cls.__name__)) + @classmethod + def is_loaded(cls): + return cls in _instances + def load_plugins(conf, ui): """Imports the modules for a sequence of plugin names. Each name diff --git a/pubs/plugs/git/git.py b/pubs/plugs/git/git.py index dd67dc8..5be9221 100644 --- a/pubs/plugs/git/git.py +++ b/pubs/plugs/git/git.py @@ -1,14 +1,16 @@ import os import sys import argparse -from subprocess import Popen, PIPE +from subprocess import Popen, PIPE, STDOUT from pipes import quote as shell_quote +from ... import uis from ...plugins import PapersPlugin from ...events import PaperChangeEvent, PostCommandEvent GITIGNORE = """# files or directories for the git plugin to ignore +.gitignore .cache/ """ @@ -26,8 +28,9 @@ class GitPlugin(PapersPlugin): def __init__(self, conf, ui): self.ui = ui - self.pubsdir = conf['main']['pubsdir'] - self.manual = conf['plugins'].get('git', {}).get('manual', False) + self.pubsdir = os.path.expanduser(conf['main']['pubsdir']) + self.manual = conf['plugins'].get('git', {}).get('manual', False) + self.force_color = conf['plugins'].get('git', {}).get('force_color', True) self.quiet = conf['plugins'].get('git', {}).get('quiet', True) self.list_of_changes = [] self._gitinit() @@ -35,13 +38,16 @@ class GitPlugin(PapersPlugin): def _gitinit(self): """Initialize the git repository if necessary.""" # check that a `.git` directory is present in the pubs dir - git_path = os.path.expanduser(os.path.join(self.pubsdir, '.git')) + git_path = os.path.join(self.pubsdir, '.git') if not os.path.isdir(git_path): - self.shell('init') + try: + self.shell('init') + except RuntimeError as exc: + self.ui.error(exc.args[0]) + sys.exit(1) # check that a `.gitignore` file is present - gitignore_path = os.path.expanduser(os.path.join(self.pubsdir, '.gitignore')) + gitignore_path = os.path.join(self.pubsdir, '.gitignore') if not os.path.isfile(gitignore_path): - print('bla') with open(gitignore_path, 'w') as fd: fd.write(GITIGNORE) @@ -55,25 +61,30 @@ class GitPlugin(PapersPlugin): def command(self, conf, args): """Execute a git command in the pubs directory""" - self.shell(' '.join([shell_quote(a) for a in args.arguments])) + self.shell(' '.join([shell_quote(a) for a in args.arguments]), command=True) - def shell(self, cmd, input_stdin=None): + def shell(self, cmd, input_stdin=None, command=False): """Runs the git program in a shell :param cmd: the git command, and all arguments, as a single string (e.g. 'add .') :param input_stdin: if Python 3, must be bytes (i.e., from str, s.encode('utf-8')) + :param command: if True, we're dealing with an explicit `pubs git` invocation. """ - git_cmd = 'git -C {} {}'.format(self.pubsdir, cmd) - p = Popen(git_cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE, shell=True) + colorize = ' -c color.ui=always' if self.force_color else '' + git_cmd = 'git -C {}{} {}'.format(self.pubsdir, colorize, cmd) + #print(git_cmd) + p = Popen(git_cmd, stdin=PIPE, stdout=PIPE, stderr=STDOUT, shell=True) output, err = p.communicate(input_stdin) p.wait() + if p.returncode != 0: - msg = ('The git plugin encountered an error when running the git command:\n' + - '{}\n{}\n'.format(git_cmd, err.decode('utf-8')) + - 'You may fix the state of the git repository {} manually.\n'.format(self.pubsdir) + - 'If relevant, you may submit a bug report at ' + - 'https://github.com/pubs/pubs/issues') - self.ui.warning(msg) + raise RuntimeError('The git plugin encountered an error when running the git command:\n' + + '{}\n\nReturned output:\n{}\n'.format(git_cmd, output.decode('utf-8')) + + 'If needed, you may fix the state of the {} git repository '.format(self.pubsdir) + + 'manually.\nIf relevant, you may submit a bug report at ' + + 'https://github.com/pubs/pubs/issues') + elif command: + self.ui.message(output.decode('utf-8'), end='') elif not self.quiet: self.ui.info(output.decode('utf-8')) return output, err, p.returncode @@ -82,26 +93,25 @@ class GitPlugin(PapersPlugin): @PaperChangeEvent.listen() def paper_change_event(event): """When a paper is changed, commit the changes to the directory.""" - try: + if GitPlugin.is_loaded(): git = GitPlugin.get_instance() if not git.manual: event_desc = event.description for a, b in [('\\','\\\\'), ('"','\\"'), ('$','\\$'), ('`','\\`')]: event_desc = event_desc.replace(a, b) git.list_of_changes.append(event_desc) - except RuntimeError: - pass @PostCommandEvent.listen() def git_commit(event): - try: - git = GitPlugin.get_instance() - if len(git.list_of_changes) > 0: - if not git.manual: - title = ' '.join(sys.argv) + '\n' - message = '\n'.join([title] + git.list_of_changes) - - git.shell('add .') - git.shell('commit -F-', message.encode('utf-8')) - except RuntimeError: - pass + if GitPlugin.is_loaded(): + try: + git = GitPlugin.get_instance() + if len(git.list_of_changes) > 0: + if not git.manual: + title = ' '.join(sys.argv) + '\n' + message = '\n'.join([title] + git.list_of_changes) + + git.shell('add .') + git.shell('commit -F-', message.encode('utf-8')) + except RuntimeError as exc: + uis.get_ui().warning(exc.args[0]) diff --git a/pubs/uis.py b/pubs/uis.py index 0ae96e5..46258ce 100644 --- a/pubs/uis.py +++ b/pubs/uis.py @@ -105,6 +105,19 @@ class PrintUI(object): self.exit() return True # never happens + def test_handle_exception(self, exc): + """Attempts to handle exception. + + :returns: True if exception has been handled (currently never happens) + """ + self.error(ustr(exc)) + if DEBUG or self.debug: + raise + else: + self.exit() + return True # never happens + + class InputUI(PrintUI): """UI class. Stores configuration parameters and system information. diff --git a/tests/fake_env.py b/tests/fake_env.py index d099a79..dad816d 100644 --- a/tests/fake_env.py +++ b/tests/fake_env.py @@ -9,7 +9,7 @@ import dotdot from pyfakefs import fake_filesystem, fake_filesystem_unittest from pubs.p3 import input, _fake_stdio, _get_fake_stdio_ucontent -from pubs import content, filebroker +from pubs import content, filebroker, uis # code for fake fs @@ -20,6 +20,8 @@ real_shutil = shutil real_glob = glob real_io = io +original_exception_handler = uis.InputUI.handle_exception + # capture output @@ -70,6 +72,7 @@ class FakeInput(): self.inputs = list(inputs) or [] self.module_list = module_list self._cursor = 0 + self._original_handler = None def as_global(self): for md in self.module_list: @@ -78,13 +81,11 @@ class FakeInput(): md.InputUI.editor_input = self md.InputUI.edit_file = self.input_to_file # Do not catch UnexpectedInput - original_handler = md.InputUI.handle_exception - def handler(ui, exc): if isinstance(exc, self.UnexpectedInput): raise else: - original_handler(ui, exc) + original_exception_handler(ui, exc) md.InputUI.handle_exception = handler diff --git a/tests/sand_env.py b/tests/sand_env.py index 8ada1a9..4154f66 100644 --- a/tests/sand_env.py +++ b/tests/sand_env.py @@ -8,15 +8,17 @@ import unittest import six -from pubs import pubs_cmd, color, content, uis, p3 +from pubs import pubs_cmd, color, content, uis, p3, events from pubs.config import conf from pubs.p3 import _fake_stdio, _get_fake_stdio_ucontent # makes the tests very noisy -PRINT_OUTPUT = True +PRINT_OUTPUT = False CAPTURE_OUTPUT = True +original_exception_handler = uis.InputUI.handle_exception + class FakeSystemExit(Exception): """\ @@ -71,7 +73,6 @@ class FakeInput(): input() returns 'no' input() raises IndexError """ - class UnexpectedInput(Exception): pass @@ -87,14 +88,11 @@ class FakeInput(): md.InputUI.editor_input = self md.InputUI.edit_file = self.input_to_file - # Do not catch UnexpectedInput - original_handler = md.InputUI.handle_exception - def handler(ui, exc): if isinstance(exc, self.UnexpectedInput): raise else: - original_handler(ui, exc) + original_exception_handler(ui, exc) md.InputUI.handle_exception = handler @@ -141,7 +139,6 @@ class SandboxedCommandTestCase(unittest.TestCase): def _preprocess_cmd(self, cmd): """Sandbox the pubs command into a temporary directory""" cmd_chunks = cmd.split(' ') - print(cmd, cmd_chunks[0], 'pubs') assert cmd_chunks[0] == 'pubs' prefix = ['pubs', '-c', self.default_conf_path] if cmd_chunks[1] == 'init': diff --git a/tests/test_git.py b/tests/test_git.py index 682ee2a..9eb60f8 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -59,19 +59,31 @@ class TestGitPlugin(sand_env.SandboxedCommandTestCase): self.assertEqual(hash_g, hash_h) self.assertNotEqual(hash_h, hash_i) + # # basically can't test that because each command is not completely independent in + # # SandoboxedCommands. + # # will work if we use subprocess. + # conf = config.load_conf(path=self.default_conf_path) + # conf['plugins']['active'] = [] + # config.save_conf(conf, path=self.default_conf_path) + # + # self.execute_cmds([('pubs add data/pagerank.bib',)]) + # hash_j = git_hash(self.default_pubs_dir) + # + # self.assertEqual(hash_i, hash_j) + + def test_manual(self): conf = config.load_conf(path=self.default_conf_path) - conf['plugins']['active'] = [] + conf['plugins']['active'] = ['git'] + conf['plugins']['git']['manual'] = True config.save_conf(conf, path=self.default_conf_path) + # this three lines just to initialize the git HEAD self.execute_cmds([('pubs add data/pagerank.bib',)]) - hash_j = git_hash(self.default_pubs_dir) - - self.assertEqual(hash_i, hash_j) + self.execute_cmds([('pubs git add .',)]) + self.execute_cmds([('pubs git commit -m "initial_commit"',)]) - conf = config.load_conf(path=self.default_conf_path) - conf['plugins']['active'] = ['git'] - conf['plugins']['git']['manual'] = True - config.save_conf(conf, path=self.default_conf_path) + self.execute_cmds([('pubs add data/pagerank.bib',)]) + hash_j = git_hash(self.default_pubs_dir) self.execute_cmds([('pubs add data/pagerank.bib',)]) hash_k = git_hash(self.default_pubs_dir)