diff --git a/.travis.yml b/.travis.yml index 095c04568..a2077b7b2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,6 +13,8 @@ install: - travis_retry pip install pylast flask # unittest backport on Python < 2.7 - "[[ $TRAVIS_PYTHON_VERSION == '2.6' ]] && pip install unittest2 || true" + - sudo apt-get update + - sudo apt-get install -qq bash-completion script: nosetests --with-coverage --cover-package=beets diff --git a/beets/ui/commands.py b/beets/ui/commands.py index f4d30feb1..d9326bd66 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -39,6 +39,7 @@ from beets.util.functemplate import Template from beets.util.confit import ConfigTypeError from beets import library from beets import config +from beets.util.confit import _package_path # Global logger. log = logging.getLogger('beets') @@ -1310,3 +1311,93 @@ def config_func(lib, opts, args): config_cmd.func = config_func default_commands.append(config_cmd) + + +# completion: print completion script + +completion_cmd = ui.Subcommand('completion', + help='print shell script that provides command line completion') +def print_completion(*args): + for line in completion_script(default_commands + plugins.commands()): + print(line, end='') + if not (os.path.isfile(u'/etc/bash_completion') or + os.path.isfile(u'/usr/share/bash-completion/bash_completion') or + os.path.isfile(u'/usr/share/local/bash-completion/bash_completion')): + log.warn(u'Warning: Unable to find the bash-completion package. ' + u'Command line completion might not work.') + +def completion_script(commands): + """Yield the full completion shell script as strings. + + ``commands`` is alist of ``ui.Subcommand`` instances to generate + completion data for. + """ + base_script = os.path.join(_package_path('beets.ui'), 'completion_base.sh') + with open(base_script, 'r') as base_script: + yield base_script.read() + + options = {} + aliases = {} + command_names = [] + + # Collect subcommands + for cmd in commands: + name = cmd.name + command_names.append(name) + + for alias in cmd.aliases: + aliases[alias] = name + + options[name] = {'flags': [], 'opts': []} + for opts in cmd.parser._get_all_options()[1:]: + if opts.action in ('store_true', 'store_false'): + option_type = 'flags' + else: + option_type = 'opts' + + options[name][option_type].extend(opts._short_opts + opts._long_opts) + + # Add global options + options['_global'] = { + 'flags': ['-v', '--verbose'], + 'opts': '-l --library -c --config -d --directory -h --help'.split(' ') + } + + # Help subcommand + command_names.append('help') + + # Add flags common to all commands + options['_common'] = { + 'flags': ['-h', '--help'] + } + + # Start generating the script + yield "_beet() {\n" + + # Command names + yield " local commands='%s'\n" % ' '.join(command_names) + yield "\n" + + # Command aliases + yield " local aliases='%s'\n" % ' '.join(aliases.keys()) + for alias, cmd in aliases.items(): + yield " local alias__%s=%s\n" % (alias, cmd) + yield '\n' + + # Fields + yield " fields='%s'\n" % ' '.join( + set(library.ITEM_KEYS + library.ALBUM_KEYS)) + + # Command options + for cmd, opts in options.items(): + for option_type, option_list in opts.items(): + if option_list: + option_list = ' '.join(option_list) + yield " local %s__%s='%s'\n" % (option_type, cmd, option_list) + + yield ' _beet_dispatch\n' + yield '}\n' + + +completion_cmd.func = print_completion +default_commands.append(completion_cmd) diff --git a/beets/ui/completion_base.sh b/beets/ui/completion_base.sh new file mode 100644 index 000000000..ce3fb6e27 --- /dev/null +++ b/beets/ui/completion_base.sh @@ -0,0 +1,162 @@ +# This file is part of beets. +# Copyright (c) 2014, Thomas Scholtes. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + + + +# Completion for the `beet` command +# ================================= +# +# Load this script to complete beets subcommands, options, and +# queries. +# +# If a beets command is found on the command line it completes filenames and +# the subcommand's options. Otherwise it will complete global options and +# subcommands. If the previous option on the command line expects an argument, +# it also completes filenames or directories. Options are only +# completed if '-' has already been typed on the command line. +# +# Note that completion of plugin commands only works for those plugins +# that were enabled when running `beet completion`. It does not check +# plugins dynamically +# +# Currently, only Bash 3.2 and newer is supported and the +# `bash-completion` package is requied. +# +# TODO +# ---- +# +# * There are some issues with arguments that are quoted on the command line. +# +# * Complete arguments for the `--format` option by expanding field variables. +# +# beet ls -f "$tit[TAB] +# beet ls -f "$title +# +# * Support long options with `=`, e.g. `--config=file`. Debian's bash +# completion package can handle this. +# + + +# Determines the beets subcommand and dispatches the completion +# accordingly. +_beet_dispatch() { + local cur prev cmd= + + COMPREPLY=() + _get_comp_words_by_ref -n : cur prev + + # Look for the beets subcommand + local arg + for (( i=1; i < COMP_CWORD; i++ )); do + arg="${COMP_WORDS[i]}" + if _list_include_item "${opts___global}" $arg; then + ((i++)) + elif [[ "$arg" != -* ]]; then + cmd="$arg" + break + fi + done + + # Replace command shortcuts + if [[ -n $cmd ]] && _list_include_item "$aliases" "$cmd"; then + eval "cmd=\$alias__$cmd" + fi + + case $cmd in + help) + COMPREPLY+=( $(compgen -W "$commands" -- $cur) ) + ;; + list|remove|move|update|write|stats) + _beet_complete_query + ;; + "") + _beet_complete_global + ;; + *) + _beet_complete + ;; + esac +} + + +# Adds option and file completion to COMPREPLY for the subcommand $cmd +_beet_complete() { + if [[ $cur == -* ]]; then + local opts flags completions + eval "opts=\$opts__$cmd" + eval "flags=\$flags__$cmd" + completions="${flags___common} ${opts} ${flags}" + COMPREPLY+=( $(compgen -W "$completions" -- $cur) ) + else + _filedir + fi +} + + +# Add global options and subcommands to the completion +_beet_complete_global() { + case $prev in + -h|--help) + # Complete commands + COMPREPLY+=( $(compgen -W "$commands" -- $cur) ) + return + ;; + -l|--library|-c|--config) + # Filename completion + _filedir + return + ;; + -d|--directory) + # Directory completion + _filedir -d + return + ;; + esac + + if [[ $cur == -* ]]; then + local completions="$opts___global $flags___global" + COMPREPLY+=( $(compgen -W "$completions" -- $cur) ) + elif [[ -n $cur ]] && _list_include_item "$aliases" "$cur"; then + local cmd + eval "cmd=\$alias__$cur" + COMPREPLY+=( "$cmd" ) + else + COMPREPLY+=( $(compgen -W "$commands" -- $cur) ) + fi +} + +_beet_complete_query() { + local opts + eval "opts=\$opts__$cmd" + + if [[ $cur == -* ]] || _list_include_item "$opts" "$prev"; then + _beet_complete + elif [[ $cur != \'* && $cur != \"* && + $cur != *:* ]]; then + # Do not complete quoted queries or those who already have a field + # set. + compopt -o nospace + COMPREPLY+=( $(compgen -S : -W "$fields" -- $cur) ) + return 0 + fi +} + +# Returns true if the space separated list $1 includes $2 +_list_include_item() { + [[ " $1 " == *[[:space:]]$2[[:space:]]* ]] +} + +# This is where beets dynamically adds the _beet function. This +# function sets the variables $flags, $opts, $commands, and $aliases. +complete -o filenames -F _beet beet diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index 21740a5f2..88ac6c72a 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -17,9 +17,12 @@ Command-Line Interface beet COMMAND [ARGS...] - The rest of this document describes the available commands. If you ever need - a quick list of what's available, just type ``beet help`` or ``beet help - COMMAND`` for help with a specific command. + Beets also offers command line completion via the `completion`_ + command. The rest of this document describes the available + commands. If you ever need a quick list of what's available, just + type ``beet help`` or ``beet help COMMAND`` for help with a specific + command. + Commands -------- @@ -326,6 +329,41 @@ Show or edit the user configuration. This command does one of three things: fallback option depending on your platform: ``open`` on OS X, ``xdg-open`` on Unix, and direct invocation on Windows. + +completion +`````````` + +:: + + beet completion + +Print a shell script that enables command line completion. + +The script completes the names of subcommands and (after typing +``-``) options of the given command. If you are using a command that +accepts a query, the script will also complete field names. :: + + beet list ar[TAB] + # artist: artist_credit: artist_sort: artpath: + beet list artp[TAB] + beet list artpat\: + +Don't worry about the slash in front of the colon: This is a escape +sequence for the shell and won't be seen by beets. + +Note that completion of plugin commands only works for those plugins +that were enabled when running ``beet completion``. If you add a plugin +later on you might want to re-generate the script. + +To enable completion in your current shell, type ``eval "$(beet +completion)"``. If you want to use it permanently, load the script from +your shell's rc-file. + +Completion is only tested to work on Bash 3.2 and newer. It also +requires the ``bash-completion`` package which is available OSX (through +*homebrew* or *ports*) and Linuxes. + + .. _global-flags: Global Flags diff --git a/test/rsrc/beetsplug/test.py b/test/rsrc/beetsplug/test.py index a1ad19441..792676b51 100644 --- a/test/rsrc/beetsplug/test.py +++ b/test/rsrc/beetsplug/test.py @@ -9,6 +9,10 @@ class TestPlugin(BeetsPlugin): def commands(self): test = ui.Subcommand('test') test.func = lambda *args: None + + # Used in CompletionTest + test.parser.add_option('-o', '--option', dest='my_opt') + plugin = ui.Subcommand('plugin') plugin.func = lambda *args: None return [test, plugin] diff --git a/test/test_completion.sh b/test/test_completion.sh new file mode 100644 index 000000000..88a0aca9b --- /dev/null +++ b/test/test_completion.sh @@ -0,0 +1,185 @@ +# Function stub +compopt() { return 0; } + +initcli() { + COMP_WORDS=( "beet" "$@" ) + let COMP_CWORD=${#COMP_WORDS[@]}-1 + COMP_LINE="${COMP_WORDS[@]}" + let COMP_POINT=${#COMP_LINE} + _beet +} + +completes() { + for word in "$@"; do + [[ " ${COMPREPLY[@]} " == *[[:space:]]$word[[:space:]]* ]] || return 1 + done +} + +COMMANDS='fields import list update remove + stats version modify move write + help' + +HELP_OPTS='-h --help' + + +test_commands() { + initcli '' && + completes $COMMANDS && + + initcli -v '' && + completes $COMMANDS && + + initcli -l help '' && + completes $COMMANDS && + + initcli -d list '' && + completes $COMMANDS && + + initcli -h '' && + completes $COMMANDS && + true +} + +test_command_aliases() { + initcli ls && + completes list && + + initcli l && + ! completes ls && + + initcli im && + completes import && + true +} + +test_global_opts() { + initcli - && + completes \ + -l --library \ + -d --directory \ + -h --help \ + -c --config \ + -v --verbose && + true +} + + +test_global_file_opts() { + # FIXME somehow file completion only works when the completion + # function is called by the shell completion utilities. So we can't + # test it here + initcli --library '' && + completes $(compgen -d) && + + initcli -l '' && + completes $(compgen -d) && + + initcli --config '' && + completes $(compgen -d) && + + initcli -c '' && + completes $(compgen -d) && + true +} + + +test_global_dir_opts() { + initcli --directory '' && + completes $(compgen -d) && + + initcli -d '' && + completes $(compgen -d) && + true +} + + +test_fields_command() { + initcli fields - && + completes -h --help && + + initcli fields '' && + completes $(compgen -d) && + true +} + + +test_import_files() { + initcli import '' && + completes $(compgen -d) && + + initcli import --copy -P '' && + completes $(compgen -d) && + + initcli import --log '' && + completes $(compgen -d) && + true +} + + +test_import_options() { + initcli imp - + completes \ + -h --help \ + -c --copy -C --nocopy \ + -w --write -W --nowrite \ + -a --autotag -A --noautotag \ + -p --resume -P --noresume \ + -l --log --flat +} + + +test_list_options() { + initcli list - + completes \ + -h --help \ + -a --album \ + -p --path +} + +test_list_query() { + initcli list 'x' && + [[ -z "${COMPREPLY[@]}" ]] && + + initcli list 'art' && + completes \ + 'artist:' \ + 'artpath:' && + + initcli list 'artits:x' && + [[ -z "${COMPREPLY[@]}" ]] && + true +} + +test_help_command() { + initcli help '' && + completes $COMMANDS && + true +} + +test_plugin_command() { + initcli te && + completes test && + + initcli test - && + completes -o --option && + true +} + +run_tests() { + local tests=$(set | \ + grep --extended-regexp --only-matching '^test_[a-zA-Z_]* \(\) $' |\ + grep --extended-regexp --only-matching '[a-zA-Z_]*' + ) + local fail=0 + + if [[ -n $@ ]]; then + tests="$@" + fi + + for t in $tests; do + $t || { fail=1 && echo "$t failed" >&2; } + done + return $fail +} + +run_tests "$@" && echo "completion tests passed" diff --git a/test/test_ui.py b/test/test_ui.py index 771eab84b..7052d6390 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -17,6 +17,7 @@ import os import shutil import re +import subprocess import _common from _common import unittest @@ -850,6 +851,46 @@ class PluginTest(_common.TestCase): config['plugins'] = ['test'] ui._raw_main(['test']) + +class CompletionTest(_common.TestCase): + + def test_completion(self): + # Load plugin commands + config['pluginpath'] = [os.path.join(_common.RSRC, 'beetsplug')] + config['plugins'] = ['test'] + + test_script = os.path.join(os.path.dirname(__file__), + 'test_completion.sh') + bash_completion = os.path.abspath(os.environ.get( + 'BASH_COMPLETION_SCRIPT', '/etc/bash_completion')) + + # Tests run in bash + shell = os.environ.get('BEETS_TEST_SHELL', '/bin/bash --norc') + tester = subprocess.Popen(shell.split(' '), stdin=subprocess.PIPE, + stdout=subprocess.PIPE) + + # Load bash_completion + with open(bash_completion, 'r') as bash_completion: + tester.stdin.writelines(bash_completion) + + # Load complection script + self.io.install() + ui._raw_main(['completion']) + completion_script = self.io.getoutput() + self.io.restore() + tester.stdin.writelines(completion_script) + # from beets import plugins + # for cmd in plugins.commands(): + # print(cmd.name) + + # Load testsuite + with open(test_script, 'r') as test_script: + tester.stdin.writelines(test_script) + (out, err) = tester.communicate() + if tester.returncode != 0 or out != "completion tests passed\n": + print(out) + self.fail('test/test_completion.sh did not execute properly') + def suite(): return unittest.TestLoader().loadTestsFromName(__name__)