From 2718a2e23a3e294f4261b0e8795ffe1e5f183089 Mon Sep 17 00:00:00 2001 From: "Amlesh Sivanantham (zamlz)" Date: Mon, 24 Dec 2018 15:18:44 -0800 Subject: [PATCH 01/21] Implemented git shell --- pubs/plugs/git/__init__.py | 0 pubs/plugs/git/git.py | 32 ++++++++++++++++++++++++++++++++ setup.py | 3 ++- 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 pubs/plugs/git/__init__.py create mode 100644 pubs/plugs/git/git.py diff --git a/pubs/plugs/git/__init__.py b/pubs/plugs/git/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pubs/plugs/git/git.py b/pubs/plugs/git/git.py new file mode 100644 index 0000000..3ee3fb8 --- /dev/null +++ b/pubs/plugs/git/git.py @@ -0,0 +1,32 @@ + +import subprocess +from pipes import quote as shell_quote + +from ...plugins import PapersPlugin + + +class GitPlugin(PapersPlugin): + + name = 'git' + + def __init__(self, conf): + self.description = "Run git commands in the pubs directory" + + def update_parser(self, subparsers, conf): + git_parser = self.parser(subparsers) + git_parser.set_defaults(func=self.command) + + def parser(self, parser): + self.parser = parser + p = parser.add_parser(self.name, help=self.description) + p.add_argument('arguments', nargs='*', help="look at man git") + return p + + def command(self, conf, args): + """Runs the git program in a shell""" + subprocess.call( + 'pubs_git() {{\ngit -C {} $@\n}}\npubs_git {}'.format( + conf['main']['pubsdir'], + ' '.join([shell_quote(a) for a in args.arguments]) + ), shell=True) + diff --git a/setup.py b/setup.py index aa0cded..190a98d 100644 --- a/setup.py +++ b/setup.py @@ -33,7 +33,8 @@ setup( 'pubs.commands', 'pubs.templates', 'pubs.plugs', - 'pubs.plugs.alias'], + 'pubs.plugs.alias', + 'pubs.plugs.git'], entry_points={ 'console_scripts': [ 'pubs=pubs.pubs_cmd:execute', From f12c03f13a2f5dbc61a85034f76ade168f9f24d0 Mon Sep 17 00:00:00 2001 From: "Amlesh Sivanantham (zamlz)" Date: Thu, 27 Dec 2018 18:29:45 -0800 Subject: [PATCH 02/21] Added the git listener functions for the events that were prewritten --- pubs/plugs/git/git.py | 45 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/pubs/plugs/git/git.py b/pubs/plugs/git/git.py index 3ee3fb8..f761adb 100644 --- a/pubs/plugs/git/git.py +++ b/pubs/plugs/git/git.py @@ -3,14 +3,18 @@ import subprocess from pipes import quote as shell_quote from ...plugins import PapersPlugin +from ...events import RemoveEvent, RenameEvent, AddEvent class GitPlugin(PapersPlugin): name = 'git' + pubsdir = None def __init__(self, conf): self.description = "Run git commands in the pubs directory" + # Needed for the event listening + GitPlugin.pubsdir = conf['main']['pubsdir'] def update_parser(self, subparsers, conf): git_parser = self.parser(subparsers) @@ -24,9 +28,40 @@ class GitPlugin(PapersPlugin): def command(self, conf, args): """Runs the git program in a shell""" - subprocess.call( - 'pubs_git() {{\ngit -C {} $@\n}}\npubs_git {}'.format( - conf['main']['pubsdir'], - ' '.join([shell_quote(a) for a in args.arguments]) - ), shell=True) + for a in args.arguments: + print(a) + GitPlugin.shell(' '.join([shell_quote(a) for a in args.arguments])) + + @classmethod + def shell(cls, cmd): + subprocess.call('git -C {} {}'.format(cls.pubsdir, cmd), shell=True) + + +@RenameEvent.listen() +def git_rename(RenameEventInstance): + new_key = RenameEventInstance.paper.citekey + old_key = RenameEventInstance.old_citekey + + # Stage the changes and commit + GitPlugin.shell("add \*/{}.\*".format(old_key)) + GitPlugin.shell("add \*/{}.\*".format(new_key)) + GitPlugin.shell('commit -m "Renamed {} to {}"'.format(old_key, new_key) + + +@RemoveEvent.listen() +def git_remove(RemoveEventInstance): + citekey = RemoveEventInstance.old_citekey + + # Stage the changes and commit + GitPlugin.shell("add \*/{}.\*".format(citekey)) + GitPlugin.shell('commit -m "Removed {}"'.format(citekey)) + + +@AddEvent.listen() +def git_add(AddEventInstance): + citekey = AddEventInstance.citekey + + # Stage the changes and commit + GitPlugin.shell("add \*/{}.\*".format(citekey)) + GitPlugin.shell('commit -m "Added {}"'.format(citekey)) From d4f79f6ecbf8f38204a74c84b97dbbabfc448326 Mon Sep 17 00:00:00 2001 From: "Amlesh Sivanantham (zamlz)" Date: Thu, 27 Dec 2018 18:39:18 -0800 Subject: [PATCH 03/21] Fixed syntax issue with plugin code --- pubs/plugs/git/git.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubs/plugs/git/git.py b/pubs/plugs/git/git.py index f761adb..0fbf035 100644 --- a/pubs/plugs/git/git.py +++ b/pubs/plugs/git/git.py @@ -45,7 +45,7 @@ def git_rename(RenameEventInstance): # Stage the changes and commit GitPlugin.shell("add \*/{}.\*".format(old_key)) GitPlugin.shell("add \*/{}.\*".format(new_key)) - GitPlugin.shell('commit -m "Renamed {} to {}"'.format(old_key, new_key) + GitPlugin.shell('commit -m "Renamed {} to {}"'.format(old_key, new_key)) @RemoveEvent.listen() From 7f1d8395684b83cedc044bd0ff4d3fe82015563e Mon Sep 17 00:00:00 2001 From: "Amlesh Sivanantham (zamlz)" Date: Thu, 24 Jan 2019 18:13:49 -0800 Subject: [PATCH 04/21] Added placeholders for more events. Updated repo.py to use some of the new events. --- pubs/events.py | 22 ++++++++++++++++++++++ pubs/plugs/git/git.py | 37 ++++++++++++++++++++++++++++++++----- pubs/repo.py | 10 ++++++---- 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/pubs/events.py b/pubs/events.py index 2c805e0..24666e0 100644 --- a/pubs/events.py +++ b/pubs/events.py @@ -39,3 +39,25 @@ class RenameEvent(Event): class AddEvent(Event): def __init__(self, citekey): self.citekey = citekey + + +class EditEvent(Event): + def __init__(self, citekey): + self.citekey = citekey + + +class TagEvent(Event): + def __init__(self, citekey): + self.citekey = citekey + + +class DocEvent(Event): + """possible actions: add, remove""" + def __init__(self, citekey, action): + self.citekey + self.action = action + + +class NoteEvent(Event): + def __init__(self, citekey, action): + self.citekey diff --git a/pubs/plugs/git/git.py b/pubs/plugs/git/git.py index 0fbf035..9c84c68 100644 --- a/pubs/plugs/git/git.py +++ b/pubs/plugs/git/git.py @@ -3,7 +3,7 @@ import subprocess from pipes import quote as shell_quote from ...plugins import PapersPlugin -from ...events import RemoveEvent, RenameEvent, AddEvent +from ...events import * class GitPlugin(PapersPlugin): @@ -45,16 +45,16 @@ def git_rename(RenameEventInstance): # Stage the changes and commit GitPlugin.shell("add \*/{}.\*".format(old_key)) GitPlugin.shell("add \*/{}.\*".format(new_key)) - GitPlugin.shell('commit -m "Renamed {} to {}"'.format(old_key, new_key)) + GitPlugin.shell('commit -m "Renamed citekey {} to {}"'.format(old_key, new_key)) @RemoveEvent.listen() def git_remove(RemoveEventInstance): - citekey = RemoveEventInstance.old_citekey + citekey = RemoveEventInstance.citekey # Stage the changes and commit GitPlugin.shell("add \*/{}.\*".format(citekey)) - GitPlugin.shell('commit -m "Removed {}"'.format(citekey)) + GitPlugin.shell('commit -m "Removed files for {}"'.format(citekey)) @AddEvent.listen() @@ -63,5 +63,32 @@ def git_add(AddEventInstance): # Stage the changes and commit GitPlugin.shell("add \*/{}.\*".format(citekey)) - GitPlugin.shell('commit -m "Added {}"'.format(citekey)) + GitPlugin.shell('commit -m "Added files for {}"'.format(citekey)) + + +@EditEvent.listen() +def git_edit(EditEventInstance): + pass + + +@TagEvent.listen() +def git_tag(TagEventInstance): + pass + + +@DocEvent.listen() +def git_doc(DocEventInstance): + citekey = DocEventInstance.citekey + + # Stage the changes and commit + GitPlugin.shell("add \*/{}.\*".format(citekey)) + if DocEventInstance.action == 'add': + GitPlugin.shell('commit -m "Added document for {}"'.format(citekey)) + elif DocEventInstance.action == 'remove': + GitPlugin.shell('commit -m "Removed document for {}"'.format(citekey)) + + +@NoteEvent.listen() +def git_note(NoteEventInstance): + pass diff --git a/pubs/repo.py b/pubs/repo.py index 51cd666..eedfff4 100644 --- a/pubs/repo.py +++ b/pubs/repo.py @@ -102,10 +102,8 @@ class Repository(object): def remove_paper(self, citekey, remove_doc=True, event=True): """ Remove a paper. Is silent if nothing needs to be done.""" - if event: - events.RemoveEvent(citekey).send() if remove_doc: - self.remove_doc(citekey, detach_only=True) + self.remove_doc(citekey, detach_only=True, event=False) try: self.databroker.remove_note(citekey, self.conf['main']['note_extension'], silent=True) @@ -115,8 +113,10 @@ class Repository(object): pass self.citekeys.remove(citekey) self.databroker.remove(citekey) + if event: + events.RemoveEvent(citekey).send() - def remove_doc(self, citekey, detach_only=False): + def remove_doc(self, citekey, detach_only=False, event=True): """ Remove a doc. Is silent if nothing needs to be done.""" try: metadata = self.databroker.pull_metadata(citekey) @@ -126,6 +126,8 @@ class Repository(object): p = self.pull_paper(citekey) p.docpath = None self.push_paper(p, overwrite=True, event=False) + if event: + events.DocEvent(citekey, 'remove').send() except IOError: # FIXME: if IOError is about being unable to # remove the file, we need to issue an error.I From 406f31baf014f771bfd17f95acbb78d3b9a29e1a Mon Sep 17 00:00:00 2001 From: "Amlesh Sivanantham (zamlz)" Date: Tue, 29 Jan 2019 17:48:18 -0800 Subject: [PATCH 05/21] Using instance variable instead of class instance --- pubs/plugs/git/git.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/pubs/plugs/git/git.py b/pubs/plugs/git/git.py index 9c84c68..ad1cfc4 100644 --- a/pubs/plugs/git/git.py +++ b/pubs/plugs/git/git.py @@ -9,12 +9,10 @@ from ...events import * class GitPlugin(PapersPlugin): name = 'git' - pubsdir = None + description = "Run git commands in the pubs directory" def __init__(self, conf): - self.description = "Run git commands in the pubs directory" - # Needed for the event listening - GitPlugin.pubsdir = conf['main']['pubsdir'] + self.pubsdir = conf['main']['pubsdir'] def update_parser(self, subparsers, conf): git_parser = self.parser(subparsers) @@ -43,9 +41,10 @@ def git_rename(RenameEventInstance): old_key = RenameEventInstance.old_citekey # Stage the changes and commit - GitPlugin.shell("add \*/{}.\*".format(old_key)) - GitPlugin.shell("add \*/{}.\*".format(new_key)) - GitPlugin.shell('commit -m "Renamed citekey {} to {}"'.format(old_key, new_key)) + git = GitPlugin.get_instance() + git.shell("add \*/{}.\*".format(old_key)) + git.shell("add \*/{}.\*".format(new_key)) + git.shell('commit -m "Renamed citekey {} to {}"'.format(old_key, new_key)) @RemoveEvent.listen() @@ -53,8 +52,9 @@ def git_remove(RemoveEventInstance): citekey = RemoveEventInstance.citekey # Stage the changes and commit - GitPlugin.shell("add \*/{}.\*".format(citekey)) - GitPlugin.shell('commit -m "Removed files for {}"'.format(citekey)) + git = GitPlugin.get_instance() + git.shell("add \*/{}.\*".format(citekey)) + git.shell('commit -m "Removed files for {}"'.format(citekey)) @AddEvent.listen() @@ -62,8 +62,9 @@ def git_add(AddEventInstance): citekey = AddEventInstance.citekey # Stage the changes and commit - GitPlugin.shell("add \*/{}.\*".format(citekey)) - GitPlugin.shell('commit -m "Added files for {}"'.format(citekey)) + git = GitPlugin.get_instance() + git.shell("add \*/{}.\*".format(citekey)) + git.shell('commit -m "Added files for {}"'.format(citekey)) @EditEvent.listen() @@ -81,11 +82,12 @@ def git_doc(DocEventInstance): citekey = DocEventInstance.citekey # Stage the changes and commit - GitPlugin.shell("add \*/{}.\*".format(citekey)) + git = GitPlugin.get_instance() + git.shell("add \*/{}.\*".format(citekey)) if DocEventInstance.action == 'add': - GitPlugin.shell('commit -m "Added document for {}"'.format(citekey)) + git.shell('commit -m "Added document for {}"'.format(citekey)) elif DocEventInstance.action == 'remove': - GitPlugin.shell('commit -m "Removed document for {}"'.format(citekey)) + git.shell('commit -m "Removed document for {}"'.format(citekey)) @NoteEvent.listen() From 5789916953e68456cd48cc0189ab3ecf50fcc498 Mon Sep 17 00:00:00 2001 From: "Amlesh Sivanantham (zamlz)" Date: Wed, 30 Jan 2019 22:05:50 -0800 Subject: [PATCH 06/21] The classmethod should be converted into a standard method --- pubs/plugs/git/git.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pubs/plugs/git/git.py b/pubs/plugs/git/git.py index ad1cfc4..99cbdc9 100644 --- a/pubs/plugs/git/git.py +++ b/pubs/plugs/git/git.py @@ -30,9 +30,8 @@ class GitPlugin(PapersPlugin): print(a) GitPlugin.shell(' '.join([shell_quote(a) for a in args.arguments])) - @classmethod - def shell(cls, cmd): - subprocess.call('git -C {} {}'.format(cls.pubsdir, cmd), shell=True) + def shell(self, cmd): + subprocess.call('git -C {} {}'.format(self.pubsdir, cmd), shell=True) @RenameEvent.listen() From 5c465fd3ecc6122dbc747fd70212f227aac02a19 Mon Sep 17 00:00:00 2001 From: "Amlesh Sivanantham (zamlz)" Date: Fri, 1 Feb 2019 20:04:30 -0800 Subject: [PATCH 07/21] shell should be called from self, removed debug statements --- pubs/plugs/git/git.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pubs/plugs/git/git.py b/pubs/plugs/git/git.py index 99cbdc9..a76c5a6 100644 --- a/pubs/plugs/git/git.py +++ b/pubs/plugs/git/git.py @@ -26,9 +26,7 @@ class GitPlugin(PapersPlugin): def command(self, conf, args): """Runs the git program in a shell""" - for a in args.arguments: - print(a) - GitPlugin.shell(' '.join([shell_quote(a) for a in args.arguments])) + self.shell(' '.join([shell_quote(a) for a in args.arguments])) def shell(self, cmd): subprocess.call('git -C {} {}'.format(self.pubsdir, cmd), shell=True) From 063d08183ff0bc326b1a38a23aeed1e15b75519d Mon Sep 17 00:00:00 2001 From: "Amlesh Sivanantham (zamlz)" Date: Fri, 1 Feb 2019 20:23:08 -0800 Subject: [PATCH 08/21] Removed redundent parser code --- pubs/plugs/git/git.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/pubs/plugs/git/git.py b/pubs/plugs/git/git.py index a76c5a6..8a8377a 100644 --- a/pubs/plugs/git/git.py +++ b/pubs/plugs/git/git.py @@ -15,15 +15,10 @@ class GitPlugin(PapersPlugin): self.pubsdir = conf['main']['pubsdir'] def update_parser(self, subparsers, conf): - git_parser = self.parser(subparsers) + git_parser = subparsers.add_parser(self.name, help=self.description) + git_parser.add_argument('arguments', nargs='*', help="look at man git") git_parser.set_defaults(func=self.command) - def parser(self, parser): - self.parser = parser - p = parser.add_parser(self.name, help=self.description) - p.add_argument('arguments', nargs='*', help="look at man git") - return p - def command(self, conf, args): """Runs the git program in a shell""" self.shell(' '.join([shell_quote(a) for a in args.arguments])) From b411bd16d8297ff03a55b80dd8d0ff6d91b0991c Mon Sep 17 00:00:00 2001 From: "Amlesh Sivanantham (zamlz)" Date: Thu, 28 Feb 2019 09:35:57 -0800 Subject: [PATCH 09/21] Setup PaperEvent class. Git plugin only listens to this class --- pubs/events.py | 44 ++++++++++++++--------------- pubs/plugs/git/git.py | 64 +++++++------------------------------------ pubs/repo.py | 21 ++++++++------ 3 files changed, 44 insertions(+), 85 deletions(-) diff --git a/pubs/events.py b/pubs/events.py index 24666e0..3dfbc75 100644 --- a/pubs/events.py +++ b/pubs/events.py @@ -25,39 +25,37 @@ class Event(object): return wrap -class RemoveEvent(Event): +class PaperEvent(Event): + _format = "Unknown modification of paper {citekey}." + def __init__(self, citekey): self.citekey = citekey + @property + def description(self): + return self._format.format(citekey=self.citekey) -class RenameEvent(Event): - def __init__(self, paper, old_citekey): - self.paper = paper - self.old_citekey = old_citekey - -class AddEvent(Event): - def __init__(self, citekey): - self.citekey = citekey +class AddEvent(PaperEvent): + _format = "Added paper {citekey}." -class EditEvent(Event): - def __init__(self, citekey): - self.citekey = citekey +class RemoveEvent(PaperEvent): + _format = "Removes paper {citekey}." -class TagEvent(Event): - def __init__(self, citekey): - self.citekey = citekey +class ModifyEvent(PaperEvent): + _format = "Modifies paper {citekey}." -class DocEvent(Event): - """possible actions: add, remove""" - def __init__(self, citekey, action): - self.citekey - self.action = action +class RenameEvent(PaperEvent): + _format = "Renames paper {old_citekey} to {citekey}." + def __init__(self, paper, old_citekey): + super(RenameEvent, self).__init__(paper.citekey) + self.paper = paper + self.old_citekey = old_citekey -class NoteEvent(Event): - def __init__(self, citekey, action): - self.citekey + @property + def description(self): + return self._format.format(citekey=self.citekey, old_citekey=self.old_citekey) diff --git a/pubs/plugs/git/git.py b/pubs/plugs/git/git.py index 8a8377a..82692c4 100644 --- a/pubs/plugs/git/git.py +++ b/pubs/plugs/git/git.py @@ -27,62 +27,18 @@ class GitPlugin(PapersPlugin): subprocess.call('git -C {} {}'.format(self.pubsdir, cmd), shell=True) -@RenameEvent.listen() -def git_rename(RenameEventInstance): - new_key = RenameEventInstance.paper.citekey - old_key = RenameEventInstance.old_citekey - - # Stage the changes and commit - git = GitPlugin.get_instance() - git.shell("add \*/{}.\*".format(old_key)) - git.shell("add \*/{}.\*".format(new_key)) - git.shell('commit -m "Renamed citekey {} to {}"'.format(old_key, new_key)) - - -@RemoveEvent.listen() -def git_remove(RemoveEventInstance): - citekey = RemoveEventInstance.citekey - - # Stage the changes and commit - git = GitPlugin.get_instance() - git.shell("add \*/{}.\*".format(citekey)) - git.shell('commit -m "Removed files for {}"'.format(citekey)) +@PaperEvent.listen() +def git_commit_event(PaperEventInstance): + citekey = PaperEventInstance.citekey - -@AddEvent.listen() -def git_add(AddEventInstance): - citekey = AddEventInstance.citekey + if isinstance(PaperEventInstance, RenameEvent): + old_citekey = RenameEventInstance.old_citekey + else: + old_citekey = None # Stage the changes and commit git = GitPlugin.get_instance() + if old_citekey: + git.shell("add \*/{}.\*".format(old_citekey)) git.shell("add \*/{}.\*".format(citekey)) - git.shell('commit -m "Added files for {}"'.format(citekey)) - - -@EditEvent.listen() -def git_edit(EditEventInstance): - pass - - -@TagEvent.listen() -def git_tag(TagEventInstance): - pass - - -@DocEvent.listen() -def git_doc(DocEventInstance): - citekey = DocEventInstance.citekey - - # Stage the changes and commit - git = GitPlugin.get_instance() - git.shell("add \*/{}.\*".format(citekey)) - if DocEventInstance.action == 'add': - git.shell('commit -m "Added document for {}"'.format(citekey)) - elif DocEventInstance.action == 'remove': - git.shell('commit -m "Removed document for {}"'.format(citekey)) - - -@NoteEvent.listen() -def git_note(NoteEventInstance): - pass - + git.shell('commit -m "{}"'.format(PaperEventInstance.description)) diff --git a/pubs/repo.py b/pubs/repo.py index eedfff4..69ca1d7 100644 --- a/pubs/repo.py +++ b/pubs/repo.py @@ -102,8 +102,10 @@ class Repository(object): def remove_paper(self, citekey, remove_doc=True, event=True): """ Remove a paper. Is silent if nothing needs to be done.""" + if event: + events.RemoveEvent(citekey).send() if remove_doc: - self.remove_doc(citekey, detach_only=True, event=False) + self.remove_doc(citekey, detach_only=True) try: self.databroker.remove_note(citekey, self.conf['main']['note_extension'], silent=True) @@ -113,10 +115,8 @@ class Repository(object): pass self.citekeys.remove(citekey) self.databroker.remove(citekey) - if event: - events.RemoveEvent(citekey).send() - def remove_doc(self, citekey, detach_only=False, event=True): + def remove_doc(self, citekey, detach_only=False): """ Remove a doc. Is silent if nothing needs to be done.""" try: metadata = self.databroker.pull_metadata(citekey) @@ -126,8 +126,6 @@ class Repository(object): p = self.pull_paper(citekey) p.docpath = None self.push_paper(p, overwrite=True, event=False) - if event: - events.DocEvent(citekey, 'remove').send() except IOError: # FIXME: if IOError is about being unable to # remove the file, we need to issue an error.I @@ -194,8 +192,15 @@ class Repository(object): p.docpath = docfile self.push_paper(p, overwrite=True, event=False) - def unique_citekey(self, base_key): - """Create a unique citekey for a given basekey.""" + def unique_citekey(self, base_key, bibentry): + """Create a unique citekey for a given base key. + + :param base_key: the base key in question. + :param bibentry: the bib entry to possibly generate the citekey. + """ + if not bibstruct.valid_citekey(base_key): + base_key = bibstruct.generate_citekey(bibentry) + # TODO: check that the generated citekey does not have a slash too. for n in itertools.count(): if not base_key + _base27(n) in self.citekeys: return base_key + _base27(n) From 4a61f91cb5bfc06c62ddba4e6ebcdb6f8f72e0dc Mon Sep 17 00:00:00 2001 From: "Amlesh Sivanantham (zamlz)" Date: Tue, 5 Mar 2019 11:33:35 -0800 Subject: [PATCH 10/21] Created a doc add/rm event --- pubs/events.py | 10 +++++++++- pubs/plugs/git/git.py | 15 ++++----------- pubs/repo.py | 2 ++ readme.md | 1 + 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/pubs/events.py b/pubs/events.py index 3dfbc75..e498954 100644 --- a/pubs/events.py +++ b/pubs/events.py @@ -37,13 +37,21 @@ class PaperEvent(Event): class AddEvent(PaperEvent): - _format = "Added paper {citekey}." + _format = "Adds paper {citekey}." + + +class DocAddEvent(PaperEvent): + _format = "Adds document {citekey}." class RemoveEvent(PaperEvent): _format = "Removes paper {citekey}." +class DocRemoveEvent(PaperEvent): + _format = "Removes document {citekey}." + + class ModifyEvent(PaperEvent): _format = "Modifies paper {citekey}." diff --git a/pubs/plugs/git/git.py b/pubs/plugs/git/git.py index 82692c4..64642c7 100644 --- a/pubs/plugs/git/git.py +++ b/pubs/plugs/git/git.py @@ -3,7 +3,7 @@ import subprocess from pipes import quote as shell_quote from ...plugins import PapersPlugin -from ...events import * +from ...events import PaperEvent, RenameEvent class GitPlugin(PapersPlugin): @@ -29,16 +29,9 @@ class GitPlugin(PapersPlugin): @PaperEvent.listen() def git_commit_event(PaperEventInstance): - citekey = PaperEventInstance.citekey - - if isinstance(PaperEventInstance, RenameEvent): - old_citekey = RenameEventInstance.old_citekey - else: - old_citekey = None - # Stage the changes and commit git = GitPlugin.get_instance() - if old_citekey: - git.shell("add \*/{}.\*".format(old_citekey)) - git.shell("add \*/{}.\*".format(citekey)) + if isinstance(PaperEventInstance, RenameEvent): + git.shell("add \*/{}.\*".format(PaperEventInstance.old_citekey)) + git.shell("add \*/{}.\*".format(PaperEventInstance.citekey)) git.shell('commit -m "{}"'.format(PaperEventInstance.description)) diff --git a/pubs/repo.py b/pubs/repo.py index 69ca1d7..4f67f3d 100644 --- a/pubs/repo.py +++ b/pubs/repo.py @@ -126,6 +126,7 @@ class Repository(object): p = self.pull_paper(citekey) p.docpath = None self.push_paper(p, overwrite=True, event=False) + events.DocRemoveEvent(citekey).send() except IOError: # FIXME: if IOError is about being unable to # remove the file, we need to issue an error.I @@ -191,6 +192,7 @@ class Repository(object): docfile = system_path(docfile) p.docpath = docfile self.push_paper(p, overwrite=True, event=False) + events.DocAddEvent(citekey).send() def unique_citekey(self, base_key, bibentry): """Create a unique citekey for a given base key. diff --git a/readme.md b/readme.md index 5b43e56..a66ffc1 100644 --- a/readme.md +++ b/readme.md @@ -130,3 +130,4 @@ You can access the self-documented configuration by using `pubs conf`, and all t - [Dennis Wilson](https://github.com/d9w) - [Bill Flynn](https://github.com/wflynny) - [ksunden](https://github.com/ksunden) +- [Amlesh Sivanantham](http://zamlz.org) From 4862860a7eeb20868ae81870841701d48612b76b Mon Sep 17 00:00:00 2001 From: "Amlesh Sivanantham (zamlz)" Date: Mon, 11 Mar 2019 11:58:10 -0700 Subject: [PATCH 11/21] Added git functionality for notes --- pubs/commands/note_cmd.py | 2 ++ pubs/events.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/pubs/commands/note_cmd.py b/pubs/commands/note_cmd.py index 8def803..367e799 100644 --- a/pubs/commands/note_cmd.py +++ b/pubs/commands/note_cmd.py @@ -2,6 +2,7 @@ from .. import repo from ..uis import get_ui from ..utils import resolve_citekey from ..completion import CiteKeyCompletion +from ..events import NoteEvent def parser(subparsers, conf): @@ -19,4 +20,5 @@ def command(conf, args): citekey = resolve_citekey(rp, args.citekey, ui=ui, exit_on_fail=True) notepath = rp.databroker.real_notepath(citekey, rp.conf['main']['note_extension']) ui.edit_file(notepath, temporary=False) + NoteEvent(citekey).send() rp.close() diff --git a/pubs/events.py b/pubs/events.py index e498954..df8fec4 100644 --- a/pubs/events.py +++ b/pubs/events.py @@ -67,3 +67,6 @@ class RenameEvent(PaperEvent): @property def description(self): return self._format.format(citekey=self.citekey, old_citekey=self.old_citekey) + +class NoteEvent(PaperEvent): + _format = "Modifies note {citekey}" From 6b74683fb45c315224fdeab4894f0e3ef7bc3bd4 Mon Sep 17 00:00:00 2001 From: "Amlesh Sivanantham (zamlz)" Date: Mon, 11 Mar 2019 12:29:19 -0700 Subject: [PATCH 12/21] Added events for edit command also added comments to events functions to list what uses them --- pubs/commands/edit_cmd.py | 5 +++++ pubs/events.py | 27 ++++++++++++++++++--------- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/pubs/commands/edit_cmd.py b/pubs/commands/edit_cmd.py index 8cc175b..431d19c 100644 --- a/pubs/commands/edit_cmd.py +++ b/pubs/commands/edit_cmd.py @@ -7,6 +7,7 @@ from ..uis import get_ui from ..endecoder import EnDecoder from ..utils import resolve_citekey from ..completion import CiteKeyCompletion +from ..events import ModifyEvent def parser(subparsers, conf): @@ -88,4 +89,8 @@ def command(conf, args): # else edit again # Also handle malformed bibtex and metadata + if meta: + ModifyEvent(citekey, "metadata").send() + else: + ModifyEvent(citekey, "bibtex").send() rp.close() diff --git a/pubs/events.py b/pubs/events.py index df8fec4..a31c69e 100644 --- a/pubs/events.py +++ b/pubs/events.py @@ -35,27 +35,35 @@ class PaperEvent(Event): def description(self): return self._format.format(citekey=self.citekey) - +# Used by repo.push_paper() class AddEvent(PaperEvent): _format = "Adds paper {citekey}." - +# Used by repo.push_doc() class DocAddEvent(PaperEvent): - _format = "Adds document {citekey}." - + _format = "Adds document for {citekey}." +# Used by repo.remove_paper() class RemoveEvent(PaperEvent): - _format = "Removes paper {citekey}." - + _format = "Removes paper for {citekey}." +# Used by repo.remove_doc() class DocRemoveEvent(PaperEvent): - _format = "Removes document {citekey}." - + _format = "Removes document for {citekey}." +# Used by commands.edit_cmd.command() class ModifyEvent(PaperEvent): - _format = "Modifies paper {citekey}." + _format = "Modifies {file_type} file of {citekey}." + + def __init__(self, citekey, file_type): + super(ModifyEvent, self).__init__(citekey) + self.file_type = file_type + @property + def description(self): + return self._format.format(citekey=self.citekey, file_type=self.file_type) +# Used by repo.rename_paper() class RenameEvent(PaperEvent): _format = "Renames paper {old_citekey} to {citekey}." @@ -68,5 +76,6 @@ class RenameEvent(PaperEvent): def description(self): return self._format.format(citekey=self.citekey, old_citekey=self.old_citekey) +# Used by commands.note_cmd.command() class NoteEvent(PaperEvent): _format = "Modifies note {citekey}" From 663e5ab1d0b5bd3c0512fe76e68707dc2c2ce009 Mon Sep 17 00:00:00 2001 From: "Amlesh Sivanantham (zamlz)" Date: Mon, 11 Mar 2019 13:25:15 -0700 Subject: [PATCH 13/21] Added events for tag updates --- pubs/commands/tag_cmd.py | 4 +++- pubs/events.py | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/pubs/commands/tag_cmd.py b/pubs/commands/tag_cmd.py index 08f810a..da656b4 100644 --- a/pubs/commands/tag_cmd.py +++ b/pubs/commands/tag_cmd.py @@ -26,6 +26,7 @@ from .. import pretty from .. import color from ..utils import resolve_citekey from ..completion import CiteKeyOrTagCompletion, TagModifierCompletion +from ..events import TagEvent def parser(subparsers, conf): @@ -101,7 +102,8 @@ def command(conf, args): p.add_tag(tag) for tag in remove_tags: p.remove_tag(tag) - rp.push_paper(p, overwrite=True) + rp.push_paper(p, overwrite=True, event=False) + TagEvent(citekeyOrTag).send() elif tags is not None: ui.error(ui.error('No entry found for citekey {}.'.format(citekeyOrTag))) ui.exit() diff --git a/pubs/events.py b/pubs/events.py index a31c69e..62a4640 100644 --- a/pubs/events.py +++ b/pubs/events.py @@ -51,6 +51,10 @@ class RemoveEvent(PaperEvent): class DocRemoveEvent(PaperEvent): _format = "Removes document for {citekey}." +# Used by commands.tag_cmd.command() +class TagEvent(PaperEvent): + _format = "Updates tags for {citekey}." + # Used by commands.edit_cmd.command() class ModifyEvent(PaperEvent): _format = "Modifies {file_type} file of {citekey}." @@ -78,4 +82,4 @@ class RenameEvent(PaperEvent): # Used by commands.note_cmd.command() class NoteEvent(PaperEvent): - _format = "Modifies note {citekey}" + _format = "Modifies note {citekey}." From cd8ca9e97d92449a5d6a4dd28038b73563e3e8d7 Mon Sep 17 00:00:00 2001 From: "Amlesh Sivanantham (zamlz)" Date: Mon, 11 Mar 2019 13:27:15 -0700 Subject: [PATCH 14/21] Changed url for contributor (zamlz) --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index a66ffc1..17635fb 100644 --- a/readme.md +++ b/readme.md @@ -130,4 +130,4 @@ You can access the self-documented configuration by using `pubs conf`, and all t - [Dennis Wilson](https://github.com/d9w) - [Bill Flynn](https://github.com/wflynny) - [ksunden](https://github.com/ksunden) -- [Amlesh Sivanantham](http://zamlz.org) +- [Amlesh Sivanantham](http://github.com/zamlz) From 3ee2c1eaeca2f720b82272176274ad0d48787b00 Mon Sep 17 00:00:00 2001 From: "Amlesh Sivanantham (zamlz)" Date: Thu, 21 Mar 2019 17:52:12 -0700 Subject: [PATCH 15/21] Escapes special characters in description --- pubs/plugs/git/git.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pubs/plugs/git/git.py b/pubs/plugs/git/git.py index 64642c7..b262aaa 100644 --- a/pubs/plugs/git/git.py +++ b/pubs/plugs/git/git.py @@ -34,4 +34,6 @@ def git_commit_event(PaperEventInstance): if isinstance(PaperEventInstance, RenameEvent): git.shell("add \*/{}.\*".format(PaperEventInstance.old_citekey)) git.shell("add \*/{}.\*".format(PaperEventInstance.citekey)) - git.shell('commit -m "{}"'.format(PaperEventInstance.description)) + cmesg = PaperEventInstance.description + cmesg = cmesg.replace('\\','\\\\').replace('"','\\"').replace('$','\\$').replace('`','\\`') + git.shell('commit -m "{}"'.format(cmesg)) From 0df52efcd36bb6f6b7f81577197500e11e036747 Mon Sep 17 00:00:00 2001 From: "Fabien C. Y. Benureau" Date: Sun, 5 May 2019 22:21:13 +0900 Subject: [PATCH 16/21] adding tests to git plugin - git plugin now performs `git init` - git call are checked - only one git commit per pubs command - SandboxedCommandTestCase for real hd usecase tests. - `git --help` now documents `--config` and `--force-colors` --- changelog.md | 5 +- pubs/config/spec.py | 8 ++ pubs/events.py | 31 +++-- pubs/plugins.py | 4 +- pubs/plugs/alias/alias.py | 2 +- pubs/plugs/git/git.py | 103 +++++++++++++--- pubs/pubs_cmd.py | 32 +++-- tests/sand_env.py | 254 ++++++++++++++++++++++++++++++++++++++ tests/test_git.py | 94 ++++++++++++++ 9 files changed, 489 insertions(+), 44 deletions(-) create mode 100644 tests/sand_env.py create mode 100644 tests/test_git.py diff --git a/changelog.md b/changelog.md index 73db144..b3ca1b2 100644 --- a/changelog.md +++ b/changelog.md @@ -7,8 +7,11 @@ ### Implemented enhancements -- Add `citekey` filter to `query` ([#193](https://github.com/pubs/pubs/pull/193) by [Shane Stone](https://github.com/shanewstone)) +- New git plugin to commit changes to the repository ([#193](https://github.com/pubs/pubs/pull/193) by [Amlesh Sivanantham](http://github.com/zamlz)) +- Add `citekey` filter to `query` ([#191(https://github.com/pubs/pubs/pull/191) by [Shane Stone](https://github.com/shanewstone)) + +- The `--config` and `--force-colors` command line options now appear when invoking `pubs --help` ### Fixed bugs diff --git a/pubs/config/spec.py b/pubs/config/spec.py index 76421a4..38e99fd 100644 --- a/pubs/config/spec.py +++ b/pubs/config/spec.py @@ -95,6 +95,14 @@ active = force_list(default=list('alias')) # command = !pubs list -k | wc -l # 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 +quiet = boolean(default=True) +# if True, git will not automatically commit changes +manual = boolean(default=False) + + [internal] # The version of this configuration file. Do not edit. version = string(min=5, default='{}') diff --git a/pubs/events.py b/pubs/events.py index 62a4640..b259bcf 100644 --- a/pubs/events.py +++ b/pubs/events.py @@ -25,8 +25,19 @@ class Event(object): return wrap -class PaperEvent(Event): - _format = "Unknown modification of paper {citekey}." + # Command events + +class PreCommandEvent(Event): + description = "Triggered before the command is executed" + +class PostCommandEvent(Event): + description = "Triggered after the command is executed" + + + # Paper changes + +class PaperChangeEvent(Event): + _format = "Unspecified modification of paper {citekey}." def __init__(self, citekey): self.citekey = citekey @@ -36,27 +47,27 @@ class PaperEvent(Event): return self._format.format(citekey=self.citekey) # Used by repo.push_paper() -class AddEvent(PaperEvent): +class AddEvent(PaperChangeEvent): _format = "Adds paper {citekey}." # Used by repo.push_doc() -class DocAddEvent(PaperEvent): +class DocAddEvent(PaperChangeEvent): _format = "Adds document for {citekey}." # Used by repo.remove_paper() -class RemoveEvent(PaperEvent): +class RemoveEvent(PaperChangeEvent): _format = "Removes paper for {citekey}." # Used by repo.remove_doc() -class DocRemoveEvent(PaperEvent): +class DocRemoveEvent(PaperChangeEvent): _format = "Removes document for {citekey}." # Used by commands.tag_cmd.command() -class TagEvent(PaperEvent): +class TagEvent(PaperChangeEvent): _format = "Updates tags for {citekey}." # Used by commands.edit_cmd.command() -class ModifyEvent(PaperEvent): +class ModifyEvent(PaperChangeEvent): _format = "Modifies {file_type} file of {citekey}." def __init__(self, citekey, file_type): @@ -68,7 +79,7 @@ class ModifyEvent(PaperEvent): return self._format.format(citekey=self.citekey, file_type=self.file_type) # Used by repo.rename_paper() -class RenameEvent(PaperEvent): +class RenameEvent(PaperChangeEvent): _format = "Renames paper {old_citekey} to {citekey}." def __init__(self, paper, old_citekey): @@ -81,5 +92,5 @@ class RenameEvent(PaperEvent): return self._format.format(citekey=self.citekey, old_citekey=self.old_citekey) # Used by commands.note_cmd.command() -class NoteEvent(PaperEvent): +class NoteEvent(PaperChangeEvent): _format = "Modifies note {citekey}." diff --git a/pubs/plugins.py b/pubs/plugins.py index c888715..4d1ae39 100644 --- a/pubs/plugins.py +++ b/pubs/plugins.py @@ -34,6 +34,8 @@ def load_plugins(conf, ui): package in sys.path; the module indicated should contain the PapersPlugin subclasses desired. """ + global _classes, _instances + _classes, _instances = [], {} for name in conf['plugins']['active']: if len(name) > 0: modname = '{}.{}.{}.{}'.format('pubs', PLUGIN_NAMESPACE, name, name) @@ -50,7 +52,7 @@ def load_plugins(conf, ui): if isinstance(obj, type) and issubclass(obj, PapersPlugin) \ and obj != PapersPlugin: _classes.append(obj) - _instances[obj] = obj(conf) + _instances[obj] = obj(conf, ui) def get_plugins(): diff --git a/pubs/plugs/alias/alias.py b/pubs/plugs/alias/alias.py index fbfe65a..fd9cae2 100644 --- a/pubs/plugs/alias/alias.py +++ b/pubs/plugs/alias/alias.py @@ -65,7 +65,7 @@ class AliasPlugin(PapersPlugin): name = 'alias' - def __init__(self, conf): + def __init__(self, conf, ui): self.aliases = [] if 'alias' in conf['plugins']: for name, entry in conf['plugins']['alias'].items(): diff --git a/pubs/plugs/git/git.py b/pubs/plugs/git/git.py index b262aaa..2156d9a 100644 --- a/pubs/plugs/git/git.py +++ b/pubs/plugs/git/git.py @@ -1,39 +1,106 @@ - -import subprocess +import os +import sys +import argparse +from subprocess import Popen, PIPE from pipes import quote as shell_quote from ...plugins import PapersPlugin -from ...events import PaperEvent, RenameEvent +from ...events import PaperChangeEvent, PostCommandEvent + + +GITIGNORE = """# files or directories for the git plugin to ignore +.cache/ +""" class GitPlugin(PapersPlugin): + """The git plugin creates a git repository in the pubs directory and commit the changes + to the pubs repository everytime a paper is modified. + + It also add the `pubs git` subcommand, so git commands can be executed in the git repository + from the command line. + """ name = 'git' description = "Run git commands in the pubs directory" - def __init__(self, conf): + def __init__(self, conf, ui): + self.ui = ui self.pubsdir = conf['main']['pubsdir'] + self.manual = conf['plugins'].get('git', {}).get('manual', False) + self.quiet = conf['plugins'].get('git', {}).get('quiet', True) + self.list_of_changes = [] + self._gitinit() + + def _gitinit(self): + """Initialize the git repository if necessary.""" + # check that a `.git` directory is present in the pubs dir + git_path = os.path.join(self.pubsdir, '.git') + if not os.path.isdir(git_path): + self.shell('init') + # check that a `.gitignore` file is present + gitignore_path = os.path.expanduser(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) def update_parser(self, subparsers, conf): + """Allow the usage of the pubs git command""" git_parser = subparsers.add_parser(self.name, help=self.description) - git_parser.add_argument('arguments', nargs='*', help="look at man git") + # FIXME: there may be some problems here with the -c argument being ambiguous between + # pubs and git. + git_parser.add_argument('arguments', nargs=argparse.REMAINDER, help="look at man git") git_parser.set_defaults(func=self.command) def command(self, conf, args): - """Runs the git program in a shell""" + """Execute a git command in the pubs directory""" self.shell(' '.join([shell_quote(a) for a in args.arguments])) - def shell(self, cmd): - subprocess.call('git -C {} {}'.format(self.pubsdir, cmd), shell=True) + def shell(self, cmd, input_stdin=None): + """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')) + """ + git_cmd = 'git -C {} {}'.format(self.pubsdir, cmd) + p = Popen(git_cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE, 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) + 'You may fix the state of the git ' + + 'repository manually.\nIf relevant, you may submit a bug report at ' + + 'https://github.com/pubs/pubs/issues') + self.ui.warning(msg) + elif not self.quiet: + self.ui.info(output) + return output, err, p.returncode + + +@PaperChangeEvent.listen() +def paper_change_event(event): + """When a paper is changed, commit the changes to the directory.""" + try: + 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) -@PaperEvent.listen() -def git_commit_event(PaperEventInstance): - # Stage the changes and commit - git = GitPlugin.get_instance() - if isinstance(PaperEventInstance, RenameEvent): - git.shell("add \*/{}.\*".format(PaperEventInstance.old_citekey)) - git.shell("add \*/{}.\*".format(PaperEventInstance.citekey)) - cmesg = PaperEventInstance.description - cmesg = cmesg.replace('\\','\\\\').replace('"','\\"').replace('$','\\$').replace('`','\\`') - git.shell('commit -m "{}"'.format(cmesg)) + git.shell('add .') + git.shell('commit -F-', message.encode('utf-8')) + except RuntimeError: + pass diff --git a/pubs/pubs_cmd.py b/pubs/pubs_cmd.py index 6da78f2..2cbe4a6 100644 --- a/pubs/pubs_cmd.py +++ b/pubs/pubs_cmd.py @@ -1,10 +1,13 @@ # PYTHON_ARGCOMPLETE_OK import sys +import argparse import collections + from . import uis from . import p3 from . import config +from . import events from . import commands from . import update from . import plugins @@ -38,15 +41,16 @@ CORE_CMDS = collections.OrderedDict([ def execute(raw_args=sys.argv): try: - conf_parser = p3.ArgumentParser(prog="pubs", add_help=False) - conf_parser.add_argument("-c", "--config", help="path to config file", - type=str, metavar="FILE") - conf_parser.add_argument('--force-colors', dest='force_colors', - action='store_true', default=False, - help='color are not disabled when piping to a file or other commands') - #conf_parser.add_argument("-u", "--update", help="update config if needed", - # default=False, action='store_true') - top_args, remaining_args = conf_parser.parse_known_args(raw_args[1:]) + desc = 'Pubs: your bibliography on the command line.\nVisit https://github.com/pubs/pubs for more information.' + parser = p3.ArgumentParser(prog="pubs", add_help=False, description=desc) + parser.add_argument("-c", "--config", help="path to an alternate configuration file", + type=str, metavar="FILE") + parser.add_argument('--force-colors', dest='force_colors', + action='store_true', default=False, + help='colors are not disabled when piping to a file or other commands') + #parser.add_argument("-u", "--update", help="update config if needed", + # default=False, action='store_true') + top_args, remaining_args = parser.parse_known_args(raw_args[1:]) if top_args.config: conf_path = top_args.config @@ -70,10 +74,9 @@ def execute(raw_args=sys.argv): uis.init_ui(conf, force_colors=top_args.force_colors) ui = uis.get_ui() - desc = 'Pubs: your bibliography on the command line.\nVisit https://github.com/pubs/pubs for more information.' - parser = p3.ArgumentParser(description=desc, - prog="pubs", add_help=True) - parser.add_argument('--version', action='version', version=__version__) + parser.add_argument('-v', '--version', action='version', version=__version__) + parser.add_argument('-h', '--help', action='help', default=argparse.SUPPRESS, + help='Show this help message and exit.') subparsers = parser.add_subparsers(title="commands", dest="command") # Populate the parser with core commands @@ -96,9 +99,12 @@ def execute(raw_args=sys.argv): parser.print_help(file=sys.stderr) sys.exit(2) + events.PreCommandEvent().send() args.prog = "pubs" # FIXME? args.func(conf, args) except Exception as e: if not uis.get_ui().handle_exception(e): raise + finally: + events.PostCommandEvent().send() diff --git a/tests/sand_env.py b/tests/sand_env.py new file mode 100644 index 0000000..b51fd8c --- /dev/null +++ b/tests/sand_env.py @@ -0,0 +1,254 @@ +from __future__ import print_function, unicode_literals + +import os +import sys +import shutil +import tempfile +import unittest + +import six + +from pubs import pubs_cmd, color, content, uis, p3 +from pubs.config import conf +from pubs.p3 import _fake_stdio, _get_fake_stdio_ucontent + + +# makes the tests very noisy +PRINT_OUTPUT = True +CAPTURE_OUTPUT = True + + +class FakeSystemExit(Exception): + """\ + SystemExit exceptions are replaced by FakeSystemExit in the execute_cmds() + function, so they can be catched by ExpectedFailure tests in Python 2.x. + + If a code is expected to raise SystemExit, catch FakeSystemExit instead. + + Added explicit __init__ so SystemExit.code functionality could be emulated. + Taking form from https://stackoverflow.com/a/26938914/1634191 + """ + def __init__(self, code=None, *args): + self.code = code + super(FakeSystemExit, self).__init__( + "Exited with code: {}.".format(self.code), *args) + + +# capture output + +def capture(f, verbose=False): + """Capture the stdout and stderr output. + + Useful for comparing the output with the expected one during tests. + + :param f: The function to capture output from. + :param verbose: If True, print call will still display their outputs. + If False, they will be silenced. + + """ + def newf(*args, **kwargs): + old_stderr, old_stdout = sys.stderr, sys.stdout + sys.stdout = _fake_stdio(additional_out=old_stderr if verbose else None) + sys.stderr = _fake_stdio(additional_out=old_stderr if False else None) + try: + return f(*args, **kwargs), _get_fake_stdio_ucontent(sys.stdout), _get_fake_stdio_ucontent(sys.stderr) + finally: + sys.stderr, sys.stdout = old_stderr, old_stdout + return newf + + +# scriptable input + +class FakeInput(): + """ Replace the input() command, and mock user input during tests + + Instanciate as : + input = FakeInput(['yes', 'no']) + then replace the input command in every module of the package : + input.as_global() + Then : + input() returns 'yes' + input() returns 'no' + input() raises IndexError + """ + + class UnexpectedInput(Exception): + pass + + def __init__(self, inputs, module_list=tuple()): + self.inputs = list(inputs) or [] + self.module_list = module_list + self._cursor = 0 + + def as_global(self): + for md in self.module_list: + md.input = self + if md.__name__ == 'pubs.uis': + 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) + + md.InputUI.handle_exception = handler + + def input_to_file(self, path_to_file, temporary=True): + content.write_file(path_to_file, self()) + + def add_input(self, inp): + self.inputs.append(inp) + + def __call__(self, *args, **kwargs): + try: + inp = self.inputs[self._cursor] + self._cursor += 1 + return inp + except IndexError: + raise self.UnexpectedInput('Unexpected user input in test.') + + +class SandboxedCommandTestCase(unittest.TestCase): + + maxDiff = 1000000 + + def setUp(self): + super(SandboxedCommandTestCase, self).setUp() + self.temp_dir = tempfile.mkdtemp() + self.default_pubs_dir = os.path.join(self.temp_dir, 'pubs') + self.default_conf_path = os.path.join(self.temp_dir, 'pubsrc') + os.chdir(os.path.dirname(__file__)) + + @staticmethod + def _normalize(s): + """Normalize a string for robust comparisons.""" + s = color.undye(s) + try: + s = s.decode('utf-8') + except AttributeError: + pass + return s + + def _compare_output(self, s1, s2): + if s1 is not None and s2 is not None: + return self.assertEqual(self._normalize(s1), self._normalize(s2)) + + def _preprocess_cmd(self, cmd): + """Sandbox the pubs command into a temporary directory""" + cmd_chunks = cmd.split(' ') + assert cmd_chunks[0] == 'pubs' + prefix = ['pubs', '-c', self.default_conf_path] + if cmd_chunks[1] == 'init': + return ' '.join(prefix + ['init', '-p', self.default_pubs_dir] + cmd_chunks[2:]) + else: + return ' '.join(prefix + cmd_chunks[1:]) + + def execute_cmds(self, cmds, capture_output=CAPTURE_OUTPUT): + """ Execute a list of commands, and capture their output + + A command can be a string, or a tuple of size 2, 3 or 4. + In the latter case, the command is : + 1. a string reprensenting the command to execute + 2. the user inputs to feed to the command during execution + 3. the expected output on stdout, verified with assertEqual. + 4. the expected output on stderr, verified with assertEqual. (this does not work yet) + """ + try: + outs = [] + for cmd in cmds: + inputs = [] + expected_out, expected_err = None, None + actual_cmd = cmd + if not isinstance(cmd, p3.ustr): + actual_cmd = cmd[0] + if len(cmd) >= 2 and cmd[1] is not None: # Inputs provided + inputs = cmd[1] + if len(cmd) >= 3: # Expected output provided + capture_output = True + if cmd[2] is not None: + expected_out = color.undye(cmd[2]) + if len(cmd) >= 4 and cmd[3] is not None: # Expected error output provided + expected_err = color.undye(cmd[3]) + actual_cmd = self._preprocess_cmd(actual_cmd) + # Always set fake input: test should not ask unexpected user input + input = FakeInput(inputs, [content, uis, p3]) + input.as_global() + try: + if capture_output: + execute_captured = capture(pubs_cmd.execute, verbose=PRINT_OUTPUT) + _, stdout, stderr = execute_captured(actual_cmd.split()) + self._compare_output(stdout, expected_out) + self._compare_output(stderr, expected_err) + outs.append(self._normalize(stdout)) + else: + pubs_cmd.execute(actual_cmd.split()) + except FakeInput.UnexpectedInput: + self.fail('Unexpected input asked by command: {}.'.format(actual_cmd)) + return outs + except SystemExit as exc: + exc_class, exc, tb = sys.exc_info() + if sys.version_info.major == 2: + # using six to avoid a SyntaxError in Python 3.x + six.reraise(FakeSystemExit, FakeSystemExit(*exc.args), tb) + else: + raise FakeSystemExit(*exc.args).with_traceback(tb) + + def tearDown(self): + shutil.rmtree(self.temp_dir, ignore_errors=True) + + + + ## Testing the test environments + + +class TestInput(unittest.TestCase): + """Test that the fake input mechanisms work correctly in the tests""" + + def test_input(self): + input = FakeInput(['yes', 'no']) + self.assertEqual(input(), 'yes') + self.assertEqual(input(), 'no') + with self.assertRaises(FakeInput.UnexpectedInput): + input() + + def test_input2(self): + other_input = FakeInput(['yes', 'no'], module_list=[color]) + other_input.as_global() + self.assertEqual(color.input(), 'yes') + self.assertEqual(color.input(), 'no') + with self.assertRaises(FakeInput.UnexpectedInput): + color.input() + + def test_editor_input(self): + sample_conf = conf.load_default_conf() + ui = uis.InputUI(sample_conf) + + other_input = FakeInput(['yes', 'no'], module_list=[uis]) + other_input.as_global() + self.assertEqual(ui.editor_input('fake_editor'), 'yes') + self.assertEqual(ui.editor_input('fake_editor'), 'no') + with self.assertRaises(FakeInput.UnexpectedInput): + ui.editor_input() + + +class TestSandboxedCommandTestCase(SandboxedCommandTestCase): + + def test_init_add(self): + """Simple init and add example""" + correct = ("added to pubs:\n" + "[Page99] Page, Lawrence et al. \"The PageRank Citation Ranking: Bringing Order to the Web.\" (1999) \n") + cmds = ['pubs init', + ('pubs add data/pagerank.bib', [], correct), + ('pubs add abc', [], '', 'error: File does not exist: /Users/self/Volumes/ResearchSync/projects/pubs/abc\n') + ] + self.execute_cmds(cmds) + + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tests/test_git.py b/tests/test_git.py new file mode 100644 index 0000000..b61c9e2 --- /dev/null +++ b/tests/test_git.py @@ -0,0 +1,94 @@ +import unittest +import subprocess + +import sand_env + +from pubs import config + + +def git_hash(pubsdir): + """Return the git revision""" + hash_cmd = ('git', '-C', pubsdir, 'rev-parse', 'HEAD') + return subprocess.check_output(hash_cmd) + + +class TestGitPlugin(sand_env.SandboxedCommandTestCase): + + def setUp(self, nsec_stat=True): + super(TestGitPlugin, self).setUp() + self.execute_cmds(['pubs init']) + conf = config.load_conf(path=self.default_conf_path) + conf['plugins']['active'] = ['git'] + config.save_conf(conf, path=self.default_conf_path) + + def test_git(self): + self.execute_cmds(['pubs add data/pagerank.bib']) + hash_a = git_hash(self.default_pubs_dir) + + self.execute_cmds(['pubs add data/pagerank.bib']) + hash_b = git_hash(self.default_pubs_dir) + + self.execute_cmds(['pubs rename Page99a ABC']) + hash_c = git_hash(self.default_pubs_dir) + + self.execute_cmds([('pubs remove ABC', ['y']),]) + hash_d = git_hash(self.default_pubs_dir) + + self.execute_cmds(['pubs doc add testrepo/doc/Page99.pdf Page99']) + hash_e = git_hash(self.default_pubs_dir) + + self.execute_cmds([('pubs doc remove Page99', ['y'])]) + hash_f = git_hash(self.default_pubs_dir) + + self.execute_cmds(['pubs tag Page99 bla+bli']) + hash_g = git_hash(self.default_pubs_dir) + + self.execute_cmds(['pubs list']) + hash_h = git_hash(self.default_pubs_dir) + + self.execute_cmds([('pubs edit Page99', ['@misc{Page99, title="TTT" author="X. YY"}', 'y', + '@misc{Page99, title="TTT", author="X. YY"}', ''])]) + hash_i = git_hash(self.default_pubs_dir) + + self.assertNotEqual(hash_a, hash_b) + self.assertNotEqual(hash_b, hash_c) + self.assertNotEqual(hash_c, hash_d) + self.assertNotEqual(hash_d, hash_e) + self.assertNotEqual(hash_e, hash_f) + self.assertNotEqual(hash_f, hash_g) + self.assertEqual(hash_g, hash_h) + self.assertNotEqual(hash_h, hash_i) + + 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) + + 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_k = git_hash(self.default_pubs_dir) + + self.assertEqual(hash_j, hash_k) + + self.execute_cmds(['pubs git add .']) + hash_l = git_hash(self.default_pubs_dir) + + self.assertEqual(hash_k, hash_l) + + self.execute_cmds(['pubs git commit -m "abc"']) + hash_m = git_hash(self.default_pubs_dir) + + self.assertNotEqual(hash_l, hash_m) + + + +if __name__ == '__main__': + unittest.main() From 5dd65ffa03747dd8c91a2bb23aa6c40d6c7ffe88 Mon Sep 17 00:00:00 2001 From: "Fabien C. Y. Benureau" Date: Sun, 5 May 2019 23:33:24 +0900 Subject: [PATCH 17/21] fix tests --- tests/sand_env.py | 2 +- tests/test_plug_alias.py | 21 +++++++++++++-------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/tests/sand_env.py b/tests/sand_env.py index b51fd8c..bde0282 100644 --- a/tests/sand_env.py +++ b/tests/sand_env.py @@ -244,7 +244,7 @@ class TestSandboxedCommandTestCase(SandboxedCommandTestCase): "[Page99] Page, Lawrence et al. \"The PageRank Citation Ranking: Bringing Order to the Web.\" (1999) \n") cmds = ['pubs init', ('pubs add data/pagerank.bib', [], correct), - ('pubs add abc', [], '', 'error: File does not exist: /Users/self/Volumes/ResearchSync/projects/pubs/abc\n') + #('pubs add abc', [], '', 'error: File does not exist: /Users/self/Volumes/ResearchSync/projects/pubs/abc\n') ] self.execute_cmds(cmds) diff --git a/tests/test_plug_alias.py b/tests/test_plug_alias.py index 46fff0e..63086f7 100644 --- a/tests/test_plug_alias.py +++ b/tests/test_plug_alias.py @@ -69,11 +69,11 @@ class AliasPluginTestCase(unittest.TestCase): self.conf['plugins']['active'] = ['alias'] def testAliasPluginCreated(self): - self.plugin = AliasPlugin(self.conf) + self.plugin = AliasPlugin(self.conf, None) def testAliasPluginOneCommnand(self): self.conf['plugins']['alias'] = {'print': 'open -w lpppp'} - self.plugin = AliasPlugin(self.conf) + self.plugin = AliasPlugin(self.conf, None) self.assertEqual(len(self.plugin.aliases), 1) self.assertEqual(type(self.plugin.aliases[0]), CommandAlias) self.assertEqual(self.plugin.aliases[0].name, 'print') @@ -81,7 +81,7 @@ class AliasPluginTestCase(unittest.TestCase): def testAliasPluginOneShell(self): self.conf['plugins']['alias'] = {'count': '!pubs list -k | wc -l'} - self.plugin = AliasPlugin(self.conf) + self.plugin = AliasPlugin(self.conf, None) self.assertEqual(len(self.plugin.aliases), 1) self.assertEqual(type(self.plugin.aliases[0]), ShellAlias) self.assertEqual(self.plugin.aliases[0].name, 'count') @@ -91,13 +91,13 @@ class AliasPluginTestCase(unittest.TestCase): def testAliasPluginTwoCommnands(self): self.conf['plugins']['alias'] = {'print': 'open -w lpppp', 'count': '!pubs list -k | wc -l'} - self.plugin = AliasPlugin(self.conf) + self.plugin = AliasPlugin(self.conf, None) self.assertEqual(len(self.plugin.aliases), 2) def testAliasPluginNestedDefinitionType(self): self.conf['plugins']['alias'] = {'print': {'description': 'print this', 'command': 'open -w lpppp'}} - self.plugin = AliasPlugin(self.conf) + self.plugin = AliasPlugin(self.conf, None) self.assertEqual(len(self.plugin.aliases), 1) self.assertEqual(type(self.plugin.aliases[0]), CommandAlias) self.assertEqual(self.plugin.aliases[0].name, 'print') @@ -106,7 +106,7 @@ class AliasPluginTestCase(unittest.TestCase): def testAliasPluginNestedDefinitionNoDescription(self): self.conf['plugins']['alias'] = {'print': {'command': 'open -w lpppp'}} - self.plugin = AliasPlugin(self.conf) + self.plugin = AliasPlugin(self.conf, None) self.assertEqual(len(self.plugin.aliases), 1) self.assertEqual(type(self.plugin.aliases[0]), CommandAlias) self.assertEqual(self.plugin.aliases[0].name, 'print') @@ -118,7 +118,7 @@ class AliasPluginTestCase(unittest.TestCase): self.conf['plugins']['alias'] = {'print': {'description': 'print this', 'command': 'open -w lpppp'}, 'count': '!pubs list -k | wc -l'} - self.plugin = AliasPlugin(self.conf) + self.plugin = AliasPlugin(self.conf, None) self.plugin.aliases = sorted(self.plugin.aliases, key=lambda a: a.name) self.assertEqual(len(self.plugin.aliases), 2) @@ -139,7 +139,7 @@ class AliasPluginTestCase(unittest.TestCase): self.conf['plugins']['alias'] = {'print': {'description': 'print this', 'command': 'open -w lpppp', 'count': '!pubs list -k | wc -l'}} - self.plugin = AliasPlugin(self.conf) + self.plugin = AliasPlugin(self.conf, None) self.assertEqual(len(self.plugin.aliases), 1) self.assertEqual(type(self.plugin.aliases[0]), CommandAlias) @@ -147,3 +147,8 @@ class AliasPluginTestCase(unittest.TestCase): self.assertEqual(self.plugin.aliases[0].name, 'print') self.assertEqual(self.plugin.aliases[0].description, 'print this') self.assertEqual(self.plugin.aliases[0].definition, 'open -w lpppp') + + + +if __name__ == '__main__': + unittest.main() From 6c929dbafab69b4ce752184b67ef6942de4f26ff Mon Sep 17 00:00:00 2001 From: "Fabien C. Y. Benureau" Date: Mon, 6 May 2019 00:04:04 +0900 Subject: [PATCH 18/21] fix tests for python 2.7 --- tests/sand_env.py | 24 ++++++++++++------------ tests/test_git.py | 22 +++++++++++----------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/tests/sand_env.py b/tests/sand_env.py index bde0282..8ada1a9 100644 --- a/tests/sand_env.py +++ b/tests/sand_env.py @@ -141,6 +141,7 @@ 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': @@ -163,17 +164,16 @@ class SandboxedCommandTestCase(unittest.TestCase): for cmd in cmds: inputs = [] expected_out, expected_err = None, None - actual_cmd = cmd - if not isinstance(cmd, p3.ustr): - actual_cmd = cmd[0] - if len(cmd) >= 2 and cmd[1] is not None: # Inputs provided - inputs = cmd[1] - if len(cmd) >= 3: # Expected output provided - capture_output = True - if cmd[2] is not None: - expected_out = color.undye(cmd[2]) - if len(cmd) >= 4 and cmd[3] is not None: # Expected error output provided - expected_err = color.undye(cmd[3]) + assert isinstance(cmd, tuple) + actual_cmd = cmd[0] + if len(cmd) >= 2 and cmd[1] is not None: # Inputs provided + inputs = cmd[1] + if len(cmd) >= 3: # Expected output provided + capture_output = True + if cmd[2] is not None: + expected_out = color.undye(cmd[2]) + if len(cmd) >= 4 and cmd[3] is not None: # Expected error output provided + expected_err = color.undye(cmd[3]) actual_cmd = self._preprocess_cmd(actual_cmd) # Always set fake input: test should not ask unexpected user input input = FakeInput(inputs, [content, uis, p3]) @@ -242,7 +242,7 @@ class TestSandboxedCommandTestCase(SandboxedCommandTestCase): """Simple init and add example""" correct = ("added to pubs:\n" "[Page99] Page, Lawrence et al. \"The PageRank Citation Ranking: Bringing Order to the Web.\" (1999) \n") - cmds = ['pubs init', + cmds = [('pubs init',), ('pubs add data/pagerank.bib', [], correct), #('pubs add abc', [], '', 'error: File does not exist: /Users/self/Volumes/ResearchSync/projects/pubs/abc\n') ] diff --git a/tests/test_git.py b/tests/test_git.py index b61c9e2..682ee2a 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -16,34 +16,34 @@ class TestGitPlugin(sand_env.SandboxedCommandTestCase): def setUp(self, nsec_stat=True): super(TestGitPlugin, self).setUp() - self.execute_cmds(['pubs init']) + self.execute_cmds([('pubs init',)]) conf = config.load_conf(path=self.default_conf_path) conf['plugins']['active'] = ['git'] config.save_conf(conf, path=self.default_conf_path) def test_git(self): - self.execute_cmds(['pubs add data/pagerank.bib']) + self.execute_cmds([('pubs add data/pagerank.bib',)]) hash_a = git_hash(self.default_pubs_dir) - self.execute_cmds(['pubs add data/pagerank.bib']) + self.execute_cmds([('pubs add data/pagerank.bib',)]) hash_b = git_hash(self.default_pubs_dir) - self.execute_cmds(['pubs rename Page99a ABC']) + self.execute_cmds([('pubs rename Page99a ABC',)]) hash_c = git_hash(self.default_pubs_dir) self.execute_cmds([('pubs remove ABC', ['y']),]) hash_d = git_hash(self.default_pubs_dir) - self.execute_cmds(['pubs doc add testrepo/doc/Page99.pdf Page99']) + self.execute_cmds([('pubs doc add testrepo/doc/Page99.pdf Page99',)]) hash_e = git_hash(self.default_pubs_dir) self.execute_cmds([('pubs doc remove Page99', ['y'])]) hash_f = git_hash(self.default_pubs_dir) - self.execute_cmds(['pubs tag Page99 bla+bli']) + self.execute_cmds([('pubs tag Page99 bla+bli',)]) hash_g = git_hash(self.default_pubs_dir) - self.execute_cmds(['pubs list']) + self.execute_cmds([('pubs list',)]) hash_h = git_hash(self.default_pubs_dir) self.execute_cmds([('pubs edit Page99', ['@misc{Page99, title="TTT" author="X. YY"}', 'y', @@ -63,7 +63,7 @@ class TestGitPlugin(sand_env.SandboxedCommandTestCase): conf['plugins']['active'] = [] config.save_conf(conf, path=self.default_conf_path) - self.execute_cmds(['pubs add data/pagerank.bib']) + self.execute_cmds([('pubs add data/pagerank.bib',)]) hash_j = git_hash(self.default_pubs_dir) self.assertEqual(hash_i, hash_j) @@ -73,17 +73,17 @@ class TestGitPlugin(sand_env.SandboxedCommandTestCase): conf['plugins']['git']['manual'] = True config.save_conf(conf, path=self.default_conf_path) - self.execute_cmds(['pubs add data/pagerank.bib']) + self.execute_cmds([('pubs add data/pagerank.bib',)]) hash_k = git_hash(self.default_pubs_dir) self.assertEqual(hash_j, hash_k) - self.execute_cmds(['pubs git add .']) + self.execute_cmds([('pubs git add .',)]) hash_l = git_hash(self.default_pubs_dir) self.assertEqual(hash_k, hash_l) - self.execute_cmds(['pubs git commit -m "abc"']) + self.execute_cmds([('pubs git commit -m "abc"',)]) hash_m = git_hash(self.default_pubs_dir) self.assertNotEqual(hash_l, hash_m) From 439b941de6b1d42b2462c7d4c1ebc3d542e8322a Mon Sep 17 00:00:00 2001 From: "Fabien C. Y. Benureau" Date: Mon, 6 May 2019 08:49:07 +0900 Subject: [PATCH 19/21] fix for info and warning display in pubs git also fixes .git/ presence detection. --- pubs/plugs/git/git.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pubs/plugs/git/git.py b/pubs/plugs/git/git.py index 2156d9a..dd67dc8 100644 --- a/pubs/plugs/git/git.py +++ b/pubs/plugs/git/git.py @@ -35,7 +35,7 @@ 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.join(self.pubsdir, '.git') + git_path = os.path.expanduser(os.path.join(self.pubsdir, '.git')) if not os.path.isdir(git_path): self.shell('init') # check that a `.gitignore` file is present @@ -46,7 +46,7 @@ class GitPlugin(PapersPlugin): fd.write(GITIGNORE) def update_parser(self, subparsers, conf): - """Allow the usage of the pubs git command""" + """Allow the usage of the pubs git subcommand""" git_parser = subparsers.add_parser(self.name, help=self.description) # FIXME: there may be some problems here with the -c argument being ambiguous between # pubs and git. @@ -69,12 +69,13 @@ class GitPlugin(PapersPlugin): 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) + 'You may fix the state of the git ' + - 'repository manually.\nIf relevant, you may submit a bug report at ' + + '{}\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) elif not self.quiet: - self.ui.info(output) + self.ui.info(output.decode('utf-8')) return output, err, p.returncode From e4665f734a6c8b8b159a46a83449e28ce63ef3d3 Mon Sep 17 00:00:00 2001 From: "Fabien C. Y. Benureau" Date: Wed, 5 Jun 2019 16:59:58 +0900 Subject: [PATCH 20/21] git plugin: addressed review + misc improvments * fixed annoying recursion in exception handlers (fake_env and sand_env) * "pubs git" always not quiet * color option for git ouput through "pubs git" * "pubs git" output without any "info:" prefix or extraneous new line. * is_loaded() method for plugins --- pubs/config/spec.py | 17 ++++++++-- pubs/events.py | 16 +++++----- pubs/plugins.py | 4 +++ pubs/plugs/git/git.py | 72 ++++++++++++++++++++++++------------------- pubs/uis.py | 13 ++++++++ tests/fake_env.py | 9 +++--- tests/sand_env.py | 13 +++----- tests/test_git.py | 28 ++++++++++++----- 8 files changed, 111 insertions(+), 61 deletions(-) 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) From d56bc88202e47fa9a3ccada069dd579838617cbb Mon Sep 17 00:00:00 2001 From: "Fabien C. Y. Benureau" Date: Thu, 6 Jun 2019 14:17:29 +0900 Subject: [PATCH 21/21] update changelog for PR 198 --- changelog.md | 7 +++---- readme.md | 4 +++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/changelog.md b/changelog.md index b3ca1b2..13c486b 100644 --- a/changelog.md +++ b/changelog.md @@ -7,10 +7,9 @@ ### Implemented enhancements -- New git plugin to commit changes to the repository ([#193](https://github.com/pubs/pubs/pull/193) by [Amlesh Sivanantham](http://github.com/zamlz)) - -- Add `citekey` filter to `query` ([#191(https://github.com/pubs/pubs/pull/191) by [Shane Stone](https://github.com/shanewstone)) - +- New git plugin to commit changes to the repository ([#191](https://github.com/pubs/pubs/pull/191) by [Amlesh Sivanantham](http://github.com/zamlz)) +- The import command now warn, rather than fail on existing citekeys. ([#198](https://github.com/pubs/pubs/pull/198) by [Kyle Sunden](https://github.com/ksunden)) +- Add `citekey` filter to `query` ([#193](https://github.com/pubs/pubs/pull/193) by [Shane Stone](https://github.com/shanewstone)) - The `--config` and `--force-colors` command line options now appear when invoking `pubs --help` ### Fixed bugs diff --git a/readme.md b/readme.md index bb6a852..6cf6085 100644 --- a/readme.md +++ b/readme.md @@ -130,6 +130,8 @@ You can access the self-documented configuration by using `pubs conf`, and all t ## Authors +### Creators + - [Fabien Benureau](http://fabien.benureau.com) - [Olivier Mangin](http://olivier.mangin.com) @@ -141,6 +143,6 @@ You can access the self-documented configuration by using `pubs conf`, and all t - [Tyler Earnest](https://github.com/tmearnest) - [Dennis Wilson](https://github.com/d9w) - [Bill Flynn](https://github.com/wflynny) -- [ksunden](https://github.com/ksunden) +- [Kyle Sunden](https://github.com/ksunden) - [Shane Stone](https://github.com/shanewstone) - [Amlesh Sivanantham](http://github.com/zamlz) \ No newline at end of file