diff --git a/pubs/commands/add_cmd.py b/pubs/commands/add_cmd.py index 95a07a2..8c5db77 100644 --- a/pubs/commands/add_cmd.py +++ b/pubs/commands/add_cmd.py @@ -9,7 +9,7 @@ from .. import color from .. import pretty -def parser(subparsers): +def parser(subparsers, conf): parser = subparsers.add_parser('add', help='add a paper to the repository') parser.add_argument('bibfile', nargs='?', default=None, help='bibtex file') diff --git a/pubs/commands/conf_cmd.py b/pubs/commands/conf_cmd.py index 1f5ba44..eceaf8d 100644 --- a/pubs/commands/conf_cmd.py +++ b/pubs/commands/conf_cmd.py @@ -3,7 +3,7 @@ from .. import config from .. import content -def parser(subparsers): +def parser(subparsers, conf): parser = subparsers.add_parser('conf', help='open the configuration in an editor') return parser @@ -17,7 +17,7 @@ def command(conf, args): # get modif from user ui.edit_file(config.get_confpath()) - new_conf = config.load_conf(check=False) + new_conf = config.load_conf() try: config.check_conf(new_conf) ui.message('The configuration file was updated.') diff --git a/pubs/commands/doc_cmd.py b/pubs/commands/doc_cmd.py index 96bcf0f..c8f34eb 100644 --- a/pubs/commands/doc_cmd.py +++ b/pubs/commands/doc_cmd.py @@ -6,6 +6,8 @@ from .. import color from ..uis import get_ui from .. import content from ..utils import resolve_citekey, resolve_citekey_list +from ..completion import CiteKeyCompletion + # doc --+- add $file $key [[-L|--link] | [-M|--move]] [-f|--force] # +- remove $key [$key [...]] [-f|--force] @@ -13,35 +15,46 @@ from ..utils import resolve_citekey, resolve_citekey_list # +- open $key [-w|--with $cmd] # supplements attach, open -def parser(subparsers): - doc_parser = subparsers.add_parser('doc', help='manage the document relating to a publication') - doc_subparsers = doc_parser.add_subparsers(title='document actions', help='actions to interact with the documents', - dest='action') +def parser(subparsers, conf): + doc_parser = subparsers.add_parser( + 'doc', + help='manage the document relating to a publication') + doc_subparsers = doc_parser.add_subparsers( + title='document actions', dest='action', + help='actions to interact with the documents') doc_subparsers.required = True add_parser = doc_subparsers.add_parser('add', help='add a document to a publication') add_parser.add_argument('-f', '--force', action='store_true', dest='force', default=False, - help='force overwriting an already assigned document') + help='force overwriting an already assigned document') add_parser.add_argument('document', nargs=1, help='document file to assign') - add_parser.add_argument('citekey', nargs=1, help='citekey of the publication') + add_parser.add_argument('citekey', nargs=1, help='citekey of the publication' + ).completer = CiteKeyCompletion(conf) add_exclusives = add_parser.add_mutually_exclusive_group() - add_exclusives.add_argument('-L', '--link', action='store_false', dest='link', default=False, - help='do not copy document files, just create a link') - add_exclusives.add_argument('-M', '--move', action='store_true', dest='move', default=False, - help='move document instead of of copying (ignored if --link)') + add_exclusives.add_argument( + '-L', '--link', action='store_false', dest='link', default=False, + help='do not copy document files, just create a link') + add_exclusives.add_argument( + '-M', '--move', action='store_true', dest='move', default=False, + help='move document instead of of copying (ignored if --link)') remove_parser = doc_subparsers.add_parser('remove', help='remove assigned documents from publications') - remove_parser.add_argument('citekeys', nargs='+', help='citekeys of the publications') - remove_parser.add_argument('-f', '--force', action='store_true', dest='force', default=False, - help='force removing assigned documents') + remove_parser.add_argument('citekeys', nargs='+', help='citekeys of the publications' + ).completer = CiteKeyCompletion(conf) + remove_parser.add_argument('-f', '--force', action='store_true', dest='force', + default=False, + help='force removing assigned documents') # favor key+ path over: key export_parser = doc_subparsers.add_parser('export', help='export assigned documents to given path') - export_parser.add_argument('citekeys', nargs='+', help='citekeys of the documents to export') + export_parser.add_argument('citekeys', nargs='+', + help='citekeys of the documents to export' + ).completer = CiteKeyCompletion(conf) export_parser.add_argument('path', nargs=1, help='directory to export the files to') open_parser = doc_subparsers.add_parser('open', help='open an assigned document') - open_parser.add_argument('citekey', nargs=1, help='citekey of the document to open') + open_parser.add_argument('citekey', nargs=1, help='citekey of the document to open' + ).completer = CiteKeyCompletion(conf) open_parser.add_argument('-w', '--with', dest='cmd', help='command to open the file with') return doc_parser diff --git a/pubs/commands/edit_cmd.py b/pubs/commands/edit_cmd.py index 60f04d6..0af4e5e 100644 --- a/pubs/commands/edit_cmd.py +++ b/pubs/commands/edit_cmd.py @@ -4,15 +4,19 @@ from .. import repo from ..uis import get_ui from ..endecoder import EnDecoder from ..utils import resolve_citekey +from ..completion import CiteKeyCompletion -def parser(subparsers): - parser = subparsers.add_parser('edit', - help='open the paper bibliographic file in an editor') - parser.add_argument('-m', '--meta', action='store_true', default=False, - help='edit metadata') - parser.add_argument('citekey', - help='citekey of the paper') +def parser(subparsers, conf): + parser = subparsers.add_parser( + 'edit', + help='open the paper bibliographic file in an editor') + parser.add_argument( + '-m', '--meta', action='store_true', default=False, + help='edit metadata') + parser.add_argument( + 'citekey', + help='citekey of the paper').completer = CiteKeyCompletion(conf) return parser diff --git a/pubs/commands/export_cmd.py b/pubs/commands/export_cmd.py index 54e48f8..1224a03 100644 --- a/pubs/commands/export_cmd.py +++ b/pubs/commands/export_cmd.py @@ -4,12 +4,15 @@ from .. import repo from ..uis import get_ui from .. import endecoder from ..utils import resolve_citekey_list +from ..completion import CiteKeyCompletion -def parser(subparsers): + +def parser(subparsers, conf): parser = subparsers.add_parser('export', help='export bibliography') # parser.add_argument('-f', '--bib-format', default='bibtex', # help='export format') - parser.add_argument('citekeys', nargs='*', help='one or several citekeys') + parser.add_argument('citekeys', nargs='*', help='one or several citekeys' + ).completer = CiteKeyCompletion(conf) return parser diff --git a/pubs/commands/import_cmd.py b/pubs/commands/import_cmd.py index 990fc8c..03935b4 100644 --- a/pubs/commands/import_cmd.py +++ b/pubs/commands/import_cmd.py @@ -11,7 +11,7 @@ from ..uis import get_ui from ..content import system_path, read_text_file -def parser(subparsers): +def parser(subparsers, conf): parser = subparsers.add_parser('import', help='import paper(s) to the repository') parser.add_argument('bibpath', diff --git a/pubs/commands/init_cmd.py b/pubs/commands/init_cmd.py index f4f2a4c..322800e 100644 --- a/pubs/commands/init_cmd.py +++ b/pubs/commands/init_cmd.py @@ -9,7 +9,8 @@ from ..repo import Repository from ..content import system_path, check_directory from .. import config -def parser(subparsers): + +def parser(subparsers, conf): parser = subparsers.add_parser('init', help="initialize the pubs directory") parser.add_argument('-p', '--pubsdir', default=None, diff --git a/pubs/commands/list_cmd.py b/pubs/commands/list_cmd.py index 6901f08..5884cdc 100644 --- a/pubs/commands/list_cmd.py +++ b/pubs/commands/list_cmd.py @@ -10,7 +10,7 @@ class InvalidQuery(ValueError): pass -def parser(subparsers): +def parser(subparsers, conf): parser = subparsers.add_parser('list', help="list papers") parser.add_argument('-k', '--citekeys-only', action='store_true', default=False, dest='citekeys', diff --git a/pubs/commands/note_cmd.py b/pubs/commands/note_cmd.py index 8e1fab1..696f99d 100644 --- a/pubs/commands/note_cmd.py +++ b/pubs/commands/note_cmd.py @@ -2,19 +2,19 @@ from .. import repo from .. import content from ..uis import get_ui from ..utils import resolve_citekey +from ..completion import CiteKeyCompletion -def parser(subparsers): +def parser(subparsers, conf): parser = subparsers.add_parser('note', - help='edit the note attached to a paper') + help='edit the note attached to a paper') parser.add_argument('citekey', - help='citekey of the paper') + help='citekey of the paper' + ).completer = CiteKeyCompletion(conf) return parser def command(conf, args): - """ - """ ui = get_ui() rp = repo.Repository(conf) diff --git a/pubs/commands/remove_cmd.py b/pubs/commands/remove_cmd.py index 83785e2..cbeb9e9 100644 --- a/pubs/commands/remove_cmd.py +++ b/pubs/commands/remove_cmd.py @@ -3,13 +3,16 @@ from .. import color from ..uis import get_ui from ..utils import resolve_citekey_list from ..p3 import ustr +from ..completion import CiteKeyCompletion -def parser(subparsers): + +def parser(subparsers, conf): parser = subparsers.add_parser('remove', help='removes a publication') parser.add_argument('-f', '--force', action='store_true', default=None, help="does not prompt for confirmation.") parser.add_argument('citekeys', nargs='+', - help="one or several citekeys") + help="one or several citekeys" + ).completer = CiteKeyCompletion(conf) return parser diff --git a/pubs/commands/rename_cmd.py b/pubs/commands/rename_cmd.py index dd4b903..ae08f51 100644 --- a/pubs/commands/rename_cmd.py +++ b/pubs/commands/rename_cmd.py @@ -2,13 +2,15 @@ from ..uis import get_ui from .. import color from .. import repo from ..utils import resolve_citekey +from ..completion import CiteKeyCompletion -def parser(subparsers): - parser = subparsers.add_parser('rename', help='rename the citekey of a repository') - parser.add_argument('citekey', - help='current citekey') - parser.add_argument('new_citekey', - help='new citekey') + +def parser(subparsers, conf): + parser = subparsers.add_parser('rename', + help='rename the citekey of a repository') + parser.add_argument('citekey', help='current citekey' + ).completer = CiteKeyCompletion(conf) + parser.add_argument('new_citekey', help='new citekey') return parser @@ -26,7 +28,7 @@ def command(conf, args): paper = rp.pull_paper(key) rp.rename_paper(paper, args.new_citekey) ui.message("The '{}' citekey has been renamed into '{}'".format( - color.dye_out(args.citekey, 'citekey'), - color.dye_out(args.new_citekey, 'citekey'))) + color.dye_out(args.citekey, 'citekey'), + color.dye_out(args.new_citekey, 'citekey'))) rp.close() diff --git a/pubs/commands/tag_cmd.py b/pubs/commands/tag_cmd.py index 5ce91a7..b5031f9 100644 --- a/pubs/commands/tag_cmd.py +++ b/pubs/commands/tag_cmd.py @@ -24,15 +24,17 @@ from ..uis import get_ui from .. import pretty from .. import color from ..utils import resolve_citekey +from ..completion import CiteKeyOrTagCompletion, TagModifierCompletion -def parser(subparsers): +def parser(subparsers, conf): parser = subparsers.add_parser('tag', help="add, remove and show tags") parser.add_argument('citekeyOrTag', nargs='?', default=None, - help='citekey or tag.') + help='citekey or tag.').completer = CiteKeyOrTagCompletion(conf) parser.add_argument('tags', nargs='?', default=None, help='If the previous argument was a citekey, then ' - 'a list of tags separated by a +.') + 'a list of tags separated by + and -.' + ).completer = TagModifierCompletion(conf) # TODO find a way to display clear help for multiple command semantics, # indistinguisable for argparse. (fabien, 201306) return parser @@ -70,6 +72,7 @@ def _tag_groups(tags): minus_tags.append(tag[1:]) return set(plus_tags), set(minus_tags) + def command(conf, args): """Add, remove and show tags""" diff --git a/pubs/commands/websearch_cmd.py b/pubs/commands/websearch_cmd.py index ee7ee20..7599f0d 100644 --- a/pubs/commands/websearch_cmd.py +++ b/pubs/commands/websearch_cmd.py @@ -3,7 +3,8 @@ import urllib from ..uis import get_ui -def parser(subparsers): + +def parser(subparsers, conf): parser = subparsers.add_parser('websearch', help="launch a search on Google Scholar") parser.add_argument("search_string", nargs = '*', diff --git a/pubs/completion.py b/pubs/completion.py new file mode 100644 index 0000000..0b2054c --- /dev/null +++ b/pubs/completion.py @@ -0,0 +1,59 @@ +import re +try: + import argcomplete +except ImportError: + + class FakeModule: + + @staticmethod + def _fun(*args, **kwargs): + pass + + def __getattr__(self, _): + return self._fun + + argcomplete = FakeModule() + +from . import repo + + +def autocomplete(parser): + argcomplete.autocomplete(parser) + + +class BaseCompleter(object): + + def __init__(self, conf): + self.conf = conf + + def __call__(self, **kwargs): + try: + return self._complete(**kwargs) + except Exception as e: + argcomplete.warn(e) + + +class CiteKeyCompletion(BaseCompleter): + + def _complete(self, **kwargs): + rp = repo.Repository(self.conf) + return rp.citekeys + + +class CiteKeyOrTagCompletion(BaseCompleter): + + def _complete(self, **kwargs): + rp = repo.Repository(self.conf) + return rp.citekeys.union(rp.get_tags()) + + +class TagModifierCompletion(BaseCompleter): + + regxp = r"[^:+-]*$" # prefix of tag after last separator + + def _complete(self, prefix, **kwargs): + tags = repo.Repository(self.conf).get_tags() + start, _ = re.search(self.regxp, prefix).span() + partial_expr = prefix[:start] + t_prefix = prefix[start:] + return [partial_expr + t for t in tags if t.startswith(t_prefix)] diff --git a/pubs/config/__init__.py b/pubs/config/__init__.py index 0f5a133..3e8ec08 100644 --- a/pubs/config/__init__.py +++ b/pubs/config/__init__.py @@ -1,2 +1,3 @@ -from .conf import get_confpath, load_default_conf, load_conf, save_conf, check_conf +from .conf import (get_confpath, load_default_conf, load_conf, save_conf, + check_conf, ConfigurationNotFound) from .conf import default_open_cmd, post_process_conf diff --git a/pubs/config/conf.py b/pubs/config/conf.py index af2f3ce..2086601 100644 --- a/pubs/config/conf.py +++ b/pubs/config/conf.py @@ -1,7 +1,5 @@ import os import platform -import shutil - import configobj import validate @@ -11,6 +9,16 @@ from .spec import configspec DFT_CONFIG_PATH = os.path.expanduser('~/.pubsrc') + +class ConfigurationNotFound(IOError): + + def __init__(self, path): + super(ConfigurationNotFound, self).__init__( + "No configuration found at path {}. Maybe you need to initialize " + "your repository with `pubs init` or specify a --config argument." + "".format(path)) + + def post_process_conf(conf): """Do some post processing on the configuration""" if conf['main']['docsdir'] == 'docsdir://': @@ -50,14 +58,14 @@ def check_conf(conf): assert results == True, '{}'.format(results) # TODO: precise error dialog when parsing error -def load_conf(check=True, path=None): +def load_conf(path=None): """Load the configuration""" if path is None: path = get_confpath(verify=True) + if not os.path.exists(path): + raise ConfigurationNotFound(path) with open(path, 'rb') as f: conf = configobj.ConfigObj(f.readlines(), configspec=configspec) - if check: - check_conf(conf) conf.filename = path conf = post_process_conf(conf) return conf diff --git a/pubs/plugins.py b/pubs/plugins.py index 17e2256..c888715 100644 --- a/pubs/plugins.py +++ b/pubs/plugins.py @@ -14,7 +14,7 @@ class PapersPlugin(object): name = None - def get_commands(self, subparsers): + def get_commands(self, subparsers, conf): """Populates the parser with plugins specific command. Returns iterable of pairs (command name, command function to call). """ diff --git a/pubs/plugs/alias/alias.py b/pubs/plugs/alias/alias.py index eebf1f9..7a75467 100644 --- a/pubs/plugs/alias/alias.py +++ b/pubs/plugs/alias/alias.py @@ -65,7 +65,7 @@ class AliasPlugin(PapersPlugin): for name, definition in conf['plugins']['alias'].items(): self.aliases.append(Alias.create_alias(name, definition)) - def update_parser(self, subparsers): + def update_parser(self, subparsers, conf): """Add subcommand to the provided subparser""" for alias in self.aliases: alias_parser = alias.parser(subparsers) diff --git a/pubs/pubs b/pubs/pubs index 7699f18..68fd3d7 100755 --- a/pubs/pubs +++ b/pubs/pubs @@ -1,5 +1,6 @@ #!/usr/bin/env python # -*- coding:utf-8 -*- +# PYTHON_ARGCOMPLETE_OK from pubs import pubs_cmd diff --git a/pubs/pubs_cmd.py b/pubs/pubs_cmd.py index 5b93893..bd956cc 100644 --- a/pubs/pubs_cmd.py +++ b/pubs/pubs_cmd.py @@ -7,6 +7,7 @@ from . import commands from . import update from . import plugins from .__init__ import __version__ +from .completion import autocomplete CORE_CMDS = collections.OrderedDict([ @@ -49,15 +50,18 @@ def execute(raw_args=sys.argv): conf_path = config.get_confpath(verify=False) # will be checked on load # Loading config - if len(remaining_args) > 0 and remaining_args[0] != 'init': - conf = config.load_conf(path=conf_path, check=False) + try: + conf = config.load_conf(path=conf_path) if update.update_check(conf, path=conf.filename): # an update happened, reload conf. - conf = config.load_conf(path=conf_path, check=False) + conf = config.load_conf(path=conf_path) config.check_conf(conf) - else: - conf = config.load_default_conf() - conf.filename = conf_path + except config.ConfigurationNotFound: + if len(remaining_args) == 0 or remaining_args[0] == 'init': + conf = config.load_default_conf() + conf.filename = conf_path + else: + raise uis.init_ui(conf, force_colors=top_args.force_colors) ui = uis.get_ui() @@ -70,14 +74,16 @@ def execute(raw_args=sys.argv): # Populate the parser with core commands for cmd_name, cmd_mod in CORE_CMDS.items(): - cmd_parser = cmd_mod.parser(subparsers) + cmd_parser = cmd_mod.parser(subparsers, conf) cmd_parser.set_defaults(func=cmd_mod.command) # Extend with plugin commands plugins.load_plugins(conf, ui) for p in plugins.get_plugins().values(): - p.update_parser(subparsers) + p.update_parser(subparsers, conf) + # Eventually autocomplete + autocomplete(parser) # Parse and run appropriate command args = parser.parse_args(remaining_args) args.prog = "pubs" # FIXME? diff --git a/pubs/update.py b/pubs/update.py index aab8879..1fb983f 100644 --- a/pubs/update.py +++ b/pubs/update.py @@ -1,6 +1,7 @@ import shutil import io +import sys from . import config from . import uis from . import color @@ -33,6 +34,7 @@ def update_check(conf, path=None): return False + def update(conf, code_version, repo_version, path=None): """Runs an update if necessary, and return True in that case.""" if path is None: diff --git a/readme.md b/readme.md index dfe19b4..074bde9 100644 --- a/readme.md +++ b/readme.md @@ -15,18 +15,20 @@ Pubs is built with the following principles in mind: ## Installation -Until pubs is uploaded to Pypi, the standard way to install it is to clone the repository and call `setup.py`. +Currently, the Pypi version is outdated. You can install the development version of `pubs`, which should be stable, with: - git clone https://github.com/pubs/pubs.git - cd pubs - sudo python setup.py install # remove sudo and add --user for local installation instead + pip install --upgrade git+https://github.com/pubs/pubs + +If `pubs` is already installed, you can upgrade with: + + pip install --upgrade git+https://github.com/pubs/pubs Alternatively Arch Linux users can also use the [pubs-git](https://aur.archlinux.org/packages/pubs-git/) AUR package. ## Getting started -Create your library (by default, goes to '~/.pubs/'). +Create your library (by default, goes to `~/.pubs/`). pubs init @@ -88,15 +90,29 @@ The first command defines a new subcommand: `pubs open -w evince` will be execut The second starts with a bang: `!`, and is treated as a shell command. +## Autocompletion + +For autocompletion to work, you need the [argcomplete](https://argcomplete.readthedocs.io) Python package, and Bash 4.2 or newer. For activating *bash* or *tsch* completion, consult the [argcomplete documentation](https://argcomplete.readthedocs.io/en/latest/#global-completion). + +For *zsh* completion, the global activation is not supported but bash completion compatibility can be used for pubs. For that, add the following to your `.zshrc`: + + # Enable and load bashcompinit + autoload -Uz compinit bashcompinit + compinit + bashcompinit + # Argcomplete explicit registration for pubs + eval "$(register-python-argcomplete pubs)" + + ## Need more help ? -You can access the self-documented configuration by using `pubs conf`, and all the commands's help is available with the `--help` option. Did not find an answer to your question? Drop us an issue. We may not answer right away (science comes first!) but we'll eventually look into it. +You can access the self-documented configuration by using `pubs conf`, and all the commands' help is available with the `--help` option. Did not find an answer to your question? Drop us an issue. We may not answer right away (science comes first!) but we'll eventually look into it. ## Requirements - python >= 2.7 or >= 3.3 - +- [argcomplete](https://argcomplete.readthedocs.io) (optional, for autocompletion) ## Authors diff --git a/tests/test_usecase.py b/tests/test_usecase.py index 1120141..4c275d5 100644 --- a/tests/test_usecase.py +++ b/tests/test_usecase.py @@ -33,7 +33,7 @@ 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 accepted to raise SystemExit, catch FakeSystemExit instead. + If a code is expected to raise SystemExit, catch FakeSystemExit instead. """ pass @@ -151,6 +151,13 @@ class DataCommandTestCase(CommandTestCase): # Actual tests +class TestAlone(CommandTestCase): + + def test_alone_misses_command(self): + with self.assertRaises(FakeSystemExit): + self.execute_cmds(['pubs']) + + class TestInit(CommandTestCase): def test_init(self):