docopt-completion 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: docopt-completion
3
+ Version: 0.1.0
4
+ Summary: A completion for the docopt language
5
+ License: MIT
6
+ Author: Anders Ingemann
7
+ Author-email: anders@ingemann.de
8
+ Requires-Python: >=3.11.0,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Programming Language :: Python :: 3.15
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Requires-Dist: docopt (>=0.6.2,<0.7.0)
18
+ Requires-Dist: docopt-parser (>=0.0.1,<0.0.2)
19
+ Requires-Dist: pyyaml (>=6.0,<7.0)
20
+ Requires-Dist: termcolor (>=1.1.0,<2.0.0)
21
+ Project-URL: Homepage, https://github.com/andsens/docopt.completion
22
+ Project-URL: Repository, https://github.com/andsens/docopt.completion
23
+ Description-Content-Type: text/markdown
24
+
25
+ docopt.completion
26
+ =================
27
+
28
+ A completion script generator for [docopt](http://docopt.org/) compatible help texts.
29
+
30
+ The full documentation is available on [GitHub](https://github.com/andsens/docopt.completion#docoptcompletion).
31
+
@@ -0,0 +1,11 @@
1
+ """
2
+ docopt.completion - A completion script generator for docopt compatible help texts.
3
+ """
4
+
5
+ __version__: str
6
+ try:
7
+ from .version import __version__ # type: ignore
8
+ except ImportError:
9
+ __version__ = '0.0.0-dev'
10
+
11
+ __all__ = []
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env python3
2
+ import logging
3
+ import os
4
+ import sys
5
+ import typing as T
6
+ import docopt
7
+ import termcolor
8
+ import yaml
9
+ import docopt_parser as P
10
+ from docopt_completion import __doc__ as pkg_doc, \
11
+ __name__ as root_name, __version__
12
+ from docopt_completion import intermediate, tables, bash, zsh, register
13
+
14
+ log = logging.getLogger(root_name)
15
+
16
+ __doc__: str = T.cast(str, pkg_doc) + """
17
+ Usage:
18
+ docopt-completion intermediate [FILE]
19
+ docopt-completion bash [FILE]
20
+ docopt-completion zsh [FILE]
21
+ docopt-completion register [--dir=DIR] (bash|zsh) COMMAND
22
+ docopt-completion -h
23
+ docopt-completion --version
24
+
25
+ Reads a docopt help text on stdin, except for register. FILE is an intermediate
26
+ with annotations.
27
+
28
+ Commands:
29
+ intermediate Write the YAML intermediate to stdout, or update FILE in place
30
+ keeping its annotations
31
+ bash Write a bash completion script to stdout
32
+ zsh Write a zsh completion script to stdout, for a file named _<prog>
33
+ in $fpath or to be sourced after compinit
34
+ register Write the completion for COMMAND from its --help, which
35
+ regenerates itself when COMMAND changes. Without --dir the
36
+ script goes where bash-completion loads it from, or to
37
+ ${XDG_DATA_HOME:-~/.local/share}/zsh/site-functions for zsh
38
+
39
+ Options:
40
+ --help -h Show this help screen
41
+ --version Show the docopt.completion version
42
+ """
43
+ Params = T.TypedDict('Params', {
44
+ 'intermediate': bool, 'bash': bool, 'zsh': bool, 'register': bool,
45
+ 'FILE': 'str | None', '--dir': 'str | None', 'COMMAND': 'str | None',
46
+ })
47
+
48
+ def docopt_completion(params: Params):
49
+ try:
50
+ if params['register']:
51
+ shell = 'bash' if params['bash'] else 'zsh'
52
+ directory = params['--dir'] or register.default_dir(shell, os.environ)
53
+ path = register.register(shell, T.cast(str, params['COMMAND']), directory)
54
+ log.info(f'Wrote {path}')
55
+ if shell == 'zsh':
56
+ log.info(f'Add {directory} to $fpath before compinit if it isn\'t there already')
57
+ return
58
+ text = sys.stdin.read()
59
+ previous = None
60
+ if params['FILE'] is not None and os.path.exists(params['FILE']):
61
+ with open(params['FILE']) as handle:
62
+ previous = yaml.safe_load(handle)
63
+ result, orphans = intermediate.generate(text, previous)
64
+ for key in orphans:
65
+ log.warning(f'{key} no longer matches anything in the usage, dropping it')
66
+ if params['bash']:
67
+ sys.stdout.write(bash.generate(result['prog'], tables.compile(result)))
68
+ elif params['zsh']:
69
+ sys.stdout.write(zsh.generate(result['prog'], tables.compile(result)))
70
+ else:
71
+ output = yaml.safe_dump(result, sort_keys=False, default_flow_style=None, allow_unicode=True)
72
+ if params['FILE'] is None:
73
+ sys.stdout.write(output)
74
+ else:
75
+ with open(params['FILE'], 'w') as handle:
76
+ handle.write(output)
77
+ except P.DocoptError as error:
78
+ log.error(str(error))
79
+ sys.exit(error.exit_code)
80
+
81
+
82
+ def setup_logging():
83
+ level_colors = {
84
+ logging.ERROR: 'red',
85
+ logging.WARN: 'yellow',
86
+ }
87
+
88
+ class ColorFormatter(logging.Formatter):
89
+
90
+ def format(self, record: logging.LogRecord):
91
+ record.msg = termcolor.colored(str(record.msg), level_colors.get(record.levelno, None))
92
+ return super().format(record)
93
+
94
+ stderr = logging.StreamHandler(sys.stderr)
95
+ if os.isatty(2):
96
+ stderr.setFormatter(ColorFormatter())
97
+ log.setLevel(level=logging.INFO)
98
+ log.addHandler(stderr)
99
+
100
+
101
+ def main():
102
+ setup_logging()
103
+ params = T.cast(Params, docopt.docopt(__doc__, version=__version__))
104
+ docopt_completion(params)
105
+
106
+
107
+ if __name__ == '__main__':
108
+ main()
@@ -0,0 +1,43 @@
1
+ import re
2
+ import shlex
3
+ from docopt_completion import tables as Tb, script
4
+
5
+ compgen = {
6
+ 'files': 'compopt -o filenames; mapfile -t -O "${#values[@]}" values < <(compgen -f -- "$2")',
7
+ 'dirs': 'compopt -o filenames; mapfile -t -O "${#values[@]}" values < <(compgen -d -- "$2")',
8
+ 'hosts': 'mapfile -t -O "${#values[@]}" values < <(compgen -A hostname -- "$2")',
9
+ 'users': 'mapfile -t -O "${#values[@]}" values < <(compgen -u -- "$2")',
10
+ 'groups': 'mapfile -t -O "${#values[@]}" values < <(compgen -g -- "$2")',
11
+ 'commands': 'mapfile -t -O "${#values[@]}" values < <(compgen -c -- "$2")',
12
+ 'signals': 'mapfile -t -O "${#values[@]}" values < <(compgen -A signal -- "$2")',
13
+ 'processes': 'mapfile -t -O "${#values[@]}" values < <(compgen -W "$(ps -e -o pid=)" -- "$2")',
14
+ }
15
+
16
+
17
+ def source_code(source: Tb.Source) -> str:
18
+ if isinstance(source, str):
19
+ return compgen[source]
20
+ if 'words' in source:
21
+ words = ' '.join(shlex.quote(str(word)) for word in source['words'])
22
+ return f'local candidates=({words}); for w in "${{candidates[@]}}"; do [[ $w = "$2"* ]] && values+=("$w"); done'
23
+ return f'while IFS= read -r w; do [[ $w = "$2"* ]] && values+=("$w"); done < <({source["command"]})'
24
+
25
+
26
+ def generate(prog: str, compiled: Tb.Tables, refresh: "script.Refresh | None" = None) -> str:
27
+ function = '_docopt_' + re.sub(r'[^A-Za-z0-9_]', '_', prog)
28
+ code = ''
29
+ if refresh is not None:
30
+ binary, path, regenerate = (script.quote(refresh[0]), script.quote(refresh[1]), shlex.join(refresh[2]))
31
+ code = f'''# Regenerate this script when {prog} changed since
32
+ local bin
33
+ if [[ -z ${{_docopt_refreshed-}} ]] && bin=$(type -P {shlex.quote(prog)}) && bin=$(realpath -- "$bin") \\
34
+ && [[ $bin != {binary} || $bin -nt {path} || $bin -ot {path} ]]; then
35
+ local _docopt_refreshed=1
36
+ if {regenerate} >/dev/null 2>&1; then
37
+ # shellcheck disable=SC1090
38
+ source {path}
39
+ {function} "$@"
40
+ return
41
+ fi
42
+ fi'''
43
+ return script.render('bash.sh', prog, function, compiled, source_code, 0, False, code)
@@ -0,0 +1,138 @@
1
+ # bash completion for "PROG", generated by docopt-completion
2
+
3
+ FN() {
4
+ "REFRESH"
5
+ "TABLES"
6
+ # Rejoin what COMP_WORDBREAKS split (--opt=value, host:port): pieces with no whitespace between them
7
+ local line=${COMP_LINE:0:COMP_POINT} words=() i w
8
+ line=${line#*"${COMP_WORDS[0]}"}
9
+ for ((i=1; i<=COMP_CWORD; i++)); do
10
+ w=${COMP_WORDS[i]}
11
+ if [[ -n $w && $line = "$w"* && ${#words[@]} -gt 0 ]]; then
12
+ words[${#words[@]}-1]+=$w
13
+ else
14
+ words+=("$w")
15
+ fi
16
+ line=${line#*"$w"}
17
+ done
18
+ local cur=${words[${#words[@]}-1]} piece=${COMP_WORDS[COMP_CWORD]}
19
+ # Readline replaces only what follows the last word break, strip the rest from the replies
20
+ for ((i=0; i<${#piece}; i++)); do
21
+ [[ $COMP_WORDBREAKS = *"${piece:i:1}"* ]] || break
22
+ done
23
+ [[ $i -eq ${#piece} ]] && piece=''
24
+ local strip=$((${#cur} - ${#piece}))
25
+ unset 'words[${#words[@]}-1]'
26
+
27
+ local state=0 expect=-1 positional=false used=' ' word opt pos
28
+ for word in "${words[@]}"; do
29
+ if [[ $state = -1 ]]; then
30
+ return 0
31
+ elif [[ $expect != -1 ]]; then
32
+ expect=-1
33
+ elif ! $positional && [[ $word = -- ]]; then
34
+ positional=true
35
+ state=${st_sep[state]}
36
+ elif ! $positional && [[ $word = --* ]]; then
37
+ if HELPERS_find "${word%%=*}" true; then
38
+ used+="$opt "
39
+ [[ ${opt_arg[opt]} = 1 && $word != *=* ]] && expect=$opt
40
+ fi
41
+ elif ! $positional && [[ $word = -?* ]]; then
42
+ for ((pos=1; pos<${#word}; pos++)); do
43
+ if HELPERS_find "-${word:pos:1}" false; then
44
+ used+="$opt "
45
+ if [[ ${opt_arg[opt]} = 1 ]]; then
46
+ [[ $pos -eq $((${#word}-1)) ]] && expect=$opt
47
+ break
48
+ fi
49
+ fi
50
+ done
51
+ else
52
+ pos=${st_other[state]}
53
+ for i in "${!cmd_state[@]}"; do
54
+ if [[ ${cmd_state[i]} = "$state" && ${cmd_name[i]} = "$word" ]]; then
55
+ pos=${cmd_next[i]}
56
+ break
57
+ fi
58
+ done
59
+ state=$pos
60
+ fi
61
+ done
62
+ [[ $state = -1 ]] && return 0
63
+
64
+ local items=() values=() src=-1 prefix=''
65
+ if [[ $expect != -1 ]]; then
66
+ src=${opt_src[expect]}
67
+ elif ! $positional && [[ $cur = --*=* ]]; then
68
+ HELPERS_find "${cur%%=*}" true && [[ ${opt_arg[opt]} = 1 ]] || return 0
69
+ src=${opt_src[opt]}
70
+ prefix=${cur%%=*}=
71
+ else
72
+ local options=() commands=()
73
+ if ! $positional; then
74
+ for i in ${st_opts[state]}; do
75
+ # shellcheck disable=SC2206
76
+ [[ $used = *" $i "* && ${opt_rep[i]} = 0 ]] || options+=(${opt_names[i]})
77
+ done
78
+ fi
79
+ for i in "${!cmd_state[@]}"; do
80
+ [[ ${cmd_state[i]} = "$state" ]] && commands+=("${cmd_name[i]}")
81
+ done
82
+ if ! $positional && [[ $cur = -* ]]; then
83
+ items=("${options[@]}")
84
+ elif [[ ${#commands[@]} -eq 0 && ${st_other[state]} = -1 ]]; then
85
+ items=("${options[@]}")
86
+ else
87
+ items=("${commands[@]}")
88
+ src=${st_src[state]}
89
+ fi
90
+ fi
91
+ for w in "${items[@]}"; do
92
+ [[ $w = "$cur"* ]] && COMPREPLY+=("${w:strip}")
93
+ done
94
+ [[ $src = -1 ]] && return 0
95
+ HELPERS_source "$src" "${cur#"$prefix"}"
96
+ for w in "${values[@]}"; do
97
+ w=$prefix$w
98
+ COMPREPLY+=("${w:strip}")
99
+ done
100
+ }
101
+
102
+ # Sets $opt to the option named $1, matching long options by prefix when $2 is true
103
+ HELPERS_find() {
104
+ local i n matches=() first=''
105
+ for i in "${!opt_names[@]}"; do
106
+ for n in ${opt_names[i]}; do
107
+ [[ $n = "$1" ]] && matches+=("$i")
108
+ done
109
+ done
110
+ if [[ ${#matches[@]} -eq 0 && $2 = true ]]; then
111
+ for i in "${!opt_names[@]}"; do
112
+ n=${opt_names[i]%% *}
113
+ if [[ $n = "$1"* ]]; then
114
+ [[ -n $first && $first != "$n" ]] && return 1
115
+ first=$n
116
+ matches+=("$i")
117
+ fi
118
+ done
119
+ fi
120
+ [[ ${#matches[@]} -gt 0 ]] || return 1
121
+ opt=${matches[0]}
122
+ for i in "${matches[@]}"; do
123
+ if [[ " ${st_opts[state]} " = *" $i "* ]]; then
124
+ opt=$i
125
+ return 0
126
+ fi
127
+ done
128
+ }
129
+
130
+ # Adds the candidates of source set $1 that start with $2 to $values
131
+ HELPERS_source() {
132
+ local w
133
+ case $1 in
134
+ "SOURCES"
135
+ esac
136
+ }
137
+
138
+ complete -F FN "PROG"
@@ -0,0 +1,136 @@
1
+ """
2
+ The intermediate: a YAML document describing what to complete, generated from a docopt help text.
3
+
4
+ `usage` mirrors the usage section as nested groups whose leaves are keys into `nodes`.
5
+ `nodes` holds what was parsed (type, short, argument, default) and the annotations
6
+ (`description`, `complete`), which the user may edit and which survive regeneration.
7
+
8
+ A node key is the leaf's identifier (`<x>`, `--speed`, `move`). Adding a key that is qualified
9
+ by the commands preceding the leaf (`shoot <x>`) or by its occurrence (`<x>#2`) splits that
10
+ occurrence off into its own node on the next regeneration.
11
+ """
12
+ import re
13
+ import typing as T
14
+ import docopt_parser as P
15
+
16
+ Usage = T.Union[str, T.Dict[str, T.List["Usage"]]]
17
+ Node = T.Dict[str, T.Any]
18
+ Intermediate = T.Dict[str, T.Any]
19
+
20
+ annotations = ('description', 'complete')
21
+
22
+ group_names: T.Dict[type, str] = {
23
+ P.Choice: 'choice', P.Sequence: 'sequence', P.Optional: 'optional', P.Repeatable: 'repeatable',
24
+ }
25
+ leaf_types: T.Dict[type, str] = {
26
+ P.Command: 'command', P.Argument: 'argument', P.ArgumentSeparator: 'separator', P.Option: 'option',
27
+ }
28
+
29
+ # Last word of a placeholder name -> builtin completion source
30
+ placeholder_sources = {
31
+ 'file': 'files', 'path': 'files',
32
+ 'dir': 'dirs', 'directory': 'dirs',
33
+ 'host': 'hosts', 'hostname': 'hosts',
34
+ 'user': 'users',
35
+ 'group': 'groups',
36
+ 'cmd': 'commands', 'command': 'commands',
37
+ 'signal': 'signals', 'sig': 'signals',
38
+ 'pid': 'processes',
39
+ }
40
+
41
+
42
+ def generate(text: str, previous: "Intermediate | None" = None) -> T.Tuple[Intermediate, T.List[str]]:
43
+ """
44
+ Returns the intermediate for `text` and the keys of `previous` that no longer match a node.
45
+ Annotations in `previous` are kept, missing ones are filled with defaults.
46
+ """
47
+ root = P.parse(text)
48
+ prog = P.get_prog(text).elm
49
+ old_nodes: T.Dict[str, Node] = previous['nodes'] if previous else {}
50
+ nodes: T.Dict[str, Node] = {}
51
+ occurrences: T.Dict[str, int] = {}
52
+
53
+ def resolve_key(ident: str, commands: T.List[str]) -> str:
54
+ occurrences[ident] = occurrences.get(ident, 0) + 1
55
+ if (key := f'{ident}#{occurrences[ident]}') in old_nodes:
56
+ return key
57
+ best, best_len = ident, 0
58
+ for key in old_nodes:
59
+ *qualifiers, last = key.split(' ')
60
+ if last == ident and len(qualifiers) > best_len and is_subsequence(qualifiers, commands):
61
+ best, best_len = key, len(qualifiers)
62
+ return best
63
+
64
+ def walk(node: P.Node, commands: T.List[str]) -> Usage:
65
+ if isinstance(node, P.Group):
66
+ items: T.List[Usage] = []
67
+ preceding = list(commands)
68
+ for item in node.items:
69
+ items.append(walk(item, commands if isinstance(node, P.Choice) else preceding))
70
+ if isinstance(item, P.Command):
71
+ preceding.append(item.ident)
72
+ return {group_names[type(node)]: items}
73
+ leaf = T.cast(P.Leaf, node)
74
+ ident = leaf.definition.ident if isinstance(leaf, P.Option) else leaf.ident
75
+ key = resolve_key(ident, commands)
76
+ if key not in nodes:
77
+ nodes[key] = node_entry(leaf, old_nodes.get(key))
78
+ return key
79
+
80
+ usage = walk(root, [])
81
+ return {'prog': prog, 'usage': usage, 'nodes': nodes}, [key for key in old_nodes if key not in nodes]
82
+
83
+
84
+ def node_entry(leaf: P.Leaf, old: "Node | None") -> Node:
85
+ entry: Node = {'type': leaf_types[type(leaf)]}
86
+ defaults: Node = {}
87
+ if isinstance(leaf, P.Option):
88
+ definition = leaf.definition
89
+ if definition.short_alias and definition.short_alias != definition.ident:
90
+ entry['short'] = definition.short_alias
91
+ if leaf.doc:
92
+ description = re.sub(r'\s+', ' ', re.sub(r'\[default: [^]]*\]', '', leaf.doc))
93
+ defaults['description'] = re.sub(r' ([.,;:])', r'\1', description).strip()
94
+ if leaf.argname:
95
+ entry['argument'] = leaf.argname.elm
96
+ default = leaf.default
97
+ words = default if isinstance(default, list) else [default] if isinstance(default, str) else []
98
+ if words:
99
+ entry['default'] = list(words)
100
+ defaults['complete'] = complete_default(leaf.argname.elm, words)
101
+ elif isinstance(leaf, P.Argument):
102
+ defaults['complete'] = complete_default(leaf.ident, [])
103
+ for key in annotations:
104
+ if old is not None and key in old:
105
+ entry[key] = old[key]
106
+ elif key in defaults:
107
+ entry[key] = defaults[key]
108
+ return entry
109
+
110
+
111
+ def complete_default(placeholder: str, default: T.List[str]) -> T.Any:
112
+ sources: T.List[T.Any] = []
113
+ if default:
114
+ sources.append({'words': default})
115
+ if (source := placeholder_sources.get(placeholder_word(placeholder))) is not None:
116
+ sources.append(source)
117
+ if not sources:
118
+ return 'none'
119
+ return sources[0] if len(sources) == 1 else sources
120
+
121
+
122
+ def placeholder_word(placeholder: str) -> str:
123
+ """The last word of a placeholder, lowercased and singular: <config-files> -> file"""
124
+ words = [w for w in re.split(r'[^A-Za-z0-9]+|(?<=[a-z])(?=[A-Z])', placeholder) if w]
125
+ word = words[-1].lower() if words else ''
126
+ if word not in placeholder_sources:
127
+ if word.endswith('ies') and word[:-3] + 'y' in placeholder_sources:
128
+ return word[:-3] + 'y'
129
+ if word.endswith('s') and word[:-1] in placeholder_sources:
130
+ return word[:-1]
131
+ return word
132
+
133
+
134
+ def is_subsequence(needle: T.List[str], haystack: T.List[str]) -> bool:
135
+ it = iter(haystack)
136
+ return all(item in it for item in needle)
File without changes
@@ -0,0 +1,51 @@
1
+ """
2
+ Completion for a docopt program that has none, generated from its `--help`. The script regenerates
3
+ itself when the program's resolved path or modification time changes.
4
+ """
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import typing as T
9
+ import docopt_parser as P
10
+ from docopt_completion import intermediate, tables, bash, zsh
11
+
12
+
13
+ class NotDocoptError(P.DocoptError):
14
+ pass
15
+
16
+
17
+ def default_dir(shell: str, env: T.Mapping[str, str]) -> str:
18
+ """Where bash-completion lazy-loads user completions from; zsh has no such place"""
19
+ data = env.get('XDG_DATA_HOME') or os.path.join(env['HOME'], '.local', 'share')
20
+ if shell == 'bash':
21
+ user_dir = env.get('BASH_COMPLETION_USER_DIR', '').split(':')[0] or os.path.join(data, 'bash-completion')
22
+ return os.path.join(user_dir, 'completions')
23
+ return os.path.join(data, 'zsh', 'site-functions')
24
+
25
+
26
+ def register(shell: str, command: str, directory: str) -> str:
27
+ """Writes the completion script for `command` to `directory` and returns its path"""
28
+ found = shutil.which(command)
29
+ if found is None:
30
+ raise P.DocoptError(f'{command} not found')
31
+ binary = os.path.realpath(found)
32
+ try:
33
+ text = subprocess.run([found, '--help'], capture_output=True, text=True, timeout=10, check=True).stdout
34
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
35
+ raise P.DocoptError(f'`{command} --help` failed: {error}') from error
36
+ result, _ = intermediate.generate(text)
37
+ for key, node in T.cast(T.Dict[str, T.Dict[str, T.Any]], result['nodes']).items():
38
+ if node['type'] == 'argument' and intermediate.placeholder_word(key) in ('option', 'options'):
39
+ raise NotDocoptError(f'The help of {command} looks GNU style ({key}), not docopt')
40
+ compiled = tables.compile(result)
41
+ name = os.path.basename(command)
42
+ path = os.path.join(directory, f'{name}.bash' if shell == 'bash' else f'_{name}')
43
+ refresh = (binary, path, ['docopt-completion', 'register', f'--dir={directory}', shell, command])
44
+ code = (bash if shell == 'bash' else zsh).generate(name, compiled, refresh)
45
+ os.makedirs(directory, exist_ok=True)
46
+ with open(path, 'w') as handle:
47
+ handle.write(code)
48
+ # The script carries the binary's mtime, a mismatch means the binary changed
49
+ stat = os.stat(binary)
50
+ os.utime(path, ns=(stat.st_atime_ns, stat.st_mtime_ns))
51
+ return path
@@ -0,0 +1,76 @@
1
+ import os
2
+ import re
3
+ import shlex
4
+ import typing as T
5
+ from docopt_completion import tables as Tb
6
+
7
+ # The resolved binary, the path of the script and the command that regenerates it
8
+ Refresh = T.Tuple[str, str, T.List[str]]
9
+
10
+
11
+ def quote(value: str) -> str:
12
+ """Always quoted, unlike shlex.quote: unquoted on the right of [[ x = y ]] is a pattern"""
13
+ return "'" + value.replace("'", "'\\''") + "'"
14
+
15
+
16
+ def render(template: str, prog: str, function: str, compiled: Tb.Tables,
17
+ source_code: T.Callable[[Tb.Source], str], base: int, descriptions: bool, refresh: str = '') -> str:
18
+ """
19
+ Fill a shell template with the tables as arrays. Indexes start at `base`, `base - 1` is none.
20
+ `function` is the completion function, its helpers are prefixed with `_docopt_<prog>`.
21
+ `refresh` is code to run first in the completion function.
22
+ """
23
+ source_sets: T.List[T.List[Tb.Source]] = []
24
+
25
+ def source_set(sources: T.List[Tb.Source]) -> int:
26
+ if not sources:
27
+ return base - 1
28
+ if sources not in source_sets:
29
+ source_sets.append(sources)
30
+ return source_sets.index(sources) + base
31
+
32
+ def index(value: "int | None") -> int:
33
+ return base - 1 if value is None else value + base
34
+
35
+ def array(name: str, values: T.Iterable[T.Any]) -> str:
36
+ return f'local -a {name}=(' + ' '.join(shlex.quote(str(value)) for value in values) + ')'
37
+
38
+ options = compiled.options
39
+ states = compiled.states
40
+ transitions = [(i, name, target, description) for i, state in enumerate(states)
41
+ for name, (target, description) in state.commands.items()]
42
+ table_lines = [
43
+ array('opt_names', (' '.join(o.names) for o in options)),
44
+ array('opt_arg', (int(o.argument) for o in options)),
45
+ array('opt_rep', (int(o.repeatable) for o in options)),
46
+ array('opt_src', (source_set(o.complete) for o in options)),
47
+ array('cmd_state', (index(t[0]) for t in transitions)),
48
+ array('cmd_name', (t[1] for t in transitions)),
49
+ array('cmd_next', (index(t[2]) for t in transitions)),
50
+ array('st_sep', (index(s.separator) for s in states)),
51
+ array('st_other', (index(s.other) for s in states)),
52
+ array('st_src', (source_set(s.complete) for s in states)),
53
+ array('st_opts', (' '.join(str(index(o)) for o in s.options) for s in states)),
54
+ ]
55
+ if descriptions:
56
+ table_lines += [
57
+ array('opt_desc', (o.description or '' for o in options)),
58
+ array('cmd_desc', (t[3] or '' for t in transitions)),
59
+ ]
60
+ source_lines: T.List[str] = []
61
+ for i, sources in enumerate(source_sets):
62
+ source_lines.append(f'{i + base})')
63
+ source_lines += [f' {source_code(source)}' for source in sources]
64
+ source_lines.append(' ;;')
65
+
66
+ with open(os.path.join(os.path.dirname(__file__), template)) as handle:
67
+ code = handle.read()
68
+ return (
69
+ code
70
+ .replace('HELPERS', '_docopt_' + re.sub(r'[^A-Za-z0-9_]', '_', prog))
71
+ .replace('FN', function)
72
+ .replace('"PROG"', shlex.quote(prog))
73
+ .replace('"TABLES"', '\n '.join(table_lines))
74
+ .replace('"SOURCES"', '\n '.join(source_lines))
75
+ .replace(' "REFRESH"\n', ''.join(f' {line}\n' for line in refresh.splitlines()))
76
+ )
@@ -0,0 +1,273 @@
1
+ """
2
+ Match tables compiled from the intermediate, the input for the shell generators.
3
+
4
+ The positional leaves (commands, arguments, `--`) form an automaton that is compiled to a DFA
5
+ over the command names, `--` and any other word. Options take no part in the ordering, docopt
6
+ matches them anywhere. Instead every state lists the options that lie on some path through it.
7
+
8
+ `complete()` is the reference for what the shell runtimes do with the tables.
9
+ """
10
+ import typing as T
11
+ from dataclasses import dataclass, field
12
+ from docopt_parser import DocoptError
13
+
14
+ Source = T.Union[str, T.Dict[str, T.Any]]
15
+ builtin_sources = ('files', 'dirs', 'hosts', 'users', 'groups', 'commands', 'signals', 'processes')
16
+
17
+ @dataclass
18
+ class Option:
19
+ names: T.List[str]
20
+ argument: bool
21
+ repeatable: bool
22
+ description: "str | None"
23
+ complete: T.List[Source]
24
+
25
+ @dataclass
26
+ class State:
27
+ # command name -> (next state, description)
28
+ commands: T.Dict[str, T.Tuple[int, "str | None"]] = field(default_factory=dict[str, T.Tuple[int, "str | None"]])
29
+ # next state for `--` and for any other word
30
+ separator: "int | None" = None
31
+ other: "int | None" = None
32
+ # completion sources of the arguments that `other` consumes
33
+ complete: T.List[Source] = field(default_factory=list[Source])
34
+ # indexes into Tables.options
35
+ options: T.List[int] = field(default_factory=list[int])
36
+
37
+ @dataclass
38
+ class Tables:
39
+ options: T.List[Option]
40
+ states: T.List[State]
41
+
42
+
43
+ def ident(key: str) -> str:
44
+ """`shoot <x>#2` -> `<x>`"""
45
+ return key.split(' ')[-1].split('#')[0]
46
+
47
+
48
+ def sources(key: str, complete: T.Any) -> T.List[Source]:
49
+ items = T.cast(T.List[T.Any], complete if isinstance(complete, list) else [complete])
50
+ result: T.List[Source] = []
51
+ for item in items:
52
+ if item == 'none':
53
+ continue
54
+ if item in builtin_sources:
55
+ result.append(T.cast(str, item))
56
+ continue
57
+ if isinstance(item, dict):
58
+ source = T.cast(T.Dict[str, T.Any], item)
59
+ if len(source) == 1 and (isinstance(source.get('words'), list) or isinstance(source.get('command'), str)):
60
+ result.append(source)
61
+ continue
62
+ raise DocoptError(f'{key}: unknown completion source {item!r}')
63
+ return result
64
+
65
+
66
+ Label = T.Tuple[str, str] # (node type, key)
67
+ # An NFA state and the options passed on the way to it
68
+ Item = T.Tuple[int, T.FrozenSet[str]]
69
+
70
+ def compile(intermediate: T.Dict[str, T.Any]) -> Tables:
71
+ nodes: T.Dict[str, T.Dict[str, T.Any]] = intermediate['nodes']
72
+ edges: T.List[T.List[T.Tuple["Label | None", int]]] = []
73
+ repeatable_options: T.Set[str] = set()
74
+
75
+ def new_state() -> int:
76
+ edges.append([])
77
+ return len(edges) - 1
78
+
79
+ def build(usage: T.Any, start: int, repeated: bool = False) -> int:
80
+ if isinstance(usage, str):
81
+ end = new_state()
82
+ edges[start].append(((nodes[usage]['type'], usage), end))
83
+ if repeated and nodes[usage]['type'] == 'option':
84
+ repeatable_options.add(usage)
85
+ return end
86
+ [(kind, items)] = T.cast(T.Dict[str, T.List[T.Any]], usage).items()
87
+ if kind == 'choice':
88
+ end = new_state()
89
+ for item in items:
90
+ edges[build(item, start, repeated)].append((None, end))
91
+ return end
92
+ if kind == 'optional':
93
+ # docopt makes every item of [a b] optional on its own
94
+ for item in items:
95
+ end = new_state()
96
+ edges[start].append((None, end))
97
+ edges[build(item, start, repeated)].append((None, end))
98
+ start = end
99
+ return start
100
+ if kind == 'repeatable':
101
+ loop = new_state()
102
+ edges[start].append((None, loop))
103
+ end = build({'sequence': items}, loop, True)
104
+ edges[end].append((None, loop))
105
+ return end
106
+ for item in items:
107
+ start = build(item, start, repeated)
108
+ return start
109
+
110
+ start = new_state()
111
+ accept = build(intermediate['usage'], start)
112
+
113
+ # States reachable from each state
114
+ reach: T.List[T.Set[int]] = []
115
+ for state in range(len(edges)):
116
+ seen = {state}
117
+ todo = [state]
118
+ while todo:
119
+ for _, target in edges[todo.pop()]:
120
+ if target not in seen:
121
+ seen.add(target)
122
+ todo.append(target)
123
+ reach.append(seen)
124
+ option_edges = [
125
+ (source, label[1], target) for source in range(len(edges))
126
+ for label, target in edges[source] if label is not None and label[0] == 'option'
127
+ ]
128
+
129
+ def after(state: int) -> T.Set[str]:
130
+ """Options on some path from `state` to the end"""
131
+ return {key for source, key, target in option_edges if source in reach[state] and accept in reach[target]}
132
+
133
+ def closure(items: T.Iterable["Item"]) -> T.FrozenSet["Item"]:
134
+ passed_by: T.Dict[int, T.FrozenSet[str]] = {}
135
+ todo = list(items)
136
+ while todo:
137
+ state, passed = todo.pop()
138
+ if state in passed_by and passed <= passed_by[state]:
139
+ continue
140
+ passed_by[state] = passed = passed_by.get(state, frozenset()) | passed
141
+ for label, target in edges[state]:
142
+ if label is None:
143
+ todo.append((target, passed))
144
+ elif label[0] == 'option':
145
+ todo.append((target, passed | {label[1]}))
146
+ return frozenset(passed_by.items())
147
+
148
+ option_keys = [key for key, node in nodes.items() if node['type'] == 'option']
149
+ options = [
150
+ Option(
151
+ names=[ident(key)] + ([nodes[key]['short']] if 'short' in nodes[key] else []),
152
+ argument='argument' in nodes[key],
153
+ repeatable=key in repeatable_options,
154
+ description=nodes[key].get('description'),
155
+ complete=sources(key, nodes[key].get('complete', 'none')),
156
+ ) for key in option_keys
157
+ ]
158
+
159
+ dfa: T.Dict[T.FrozenSet[Item], int] = {}
160
+ states: T.List[State] = []
161
+
162
+ def dfa_state(items: T.FrozenSet[Item]) -> "int | None":
163
+ if not items:
164
+ return None
165
+ if items in dfa:
166
+ return dfa[items]
167
+ index = dfa[items] = len(states)
168
+ state = State()
169
+ states.append(state)
170
+ outgoing = [
171
+ (label, (target, passed)) for s, passed in items
172
+ for label, target in edges[s] if label is not None and label[0] != 'option'
173
+ ]
174
+ arguments = {target for (kind, _), target in outgoing if kind == 'argument'}
175
+ for (kind, key), _ in outgoing:
176
+ if kind == 'command':
177
+ name = ident(key)
178
+ targets = {t for (k, other_key), t in outgoing if k == 'command' and ident(other_key) == name}
179
+ next_state = T.cast(int, dfa_state(closure(targets | arguments)))
180
+ description = state.commands.get(name, (0, None))[1] or nodes[key].get('description')
181
+ state.commands[name] = (next_state, description)
182
+ elif kind == 'argument':
183
+ for source in sources(key, nodes[key].get('complete', 'none')):
184
+ if source not in state.complete:
185
+ state.complete.append(source)
186
+ separators = {target for (kind, _), target in outgoing if kind == 'separator'}
187
+ state.separator = dfa_state(closure(separators | arguments))
188
+ state.other = dfa_state(closure(arguments))
189
+ in_scope = set[str]().union(*(passed | after(s) for s, passed in items))
190
+ state.options = [i for i, key in enumerate(option_keys) if key in in_scope]
191
+ return index
192
+
193
+ dfa_state(closure([(start, frozenset())]))
194
+ return Tables(options, states)
195
+
196
+
197
+ @dataclass
198
+ class Completion:
199
+ # Literal candidates with their descriptions
200
+ words: T.List[T.Tuple[str, "str | None"]] = field(default_factory=list[T.Tuple[str, "str | None"]])
201
+ # Sources for the shell to expand
202
+ sources: T.List[Source] = field(default_factory=list[Source])
203
+ # Prepended to every candidate, e.g. `--speed=`
204
+ prefix: str = ''
205
+
206
+
207
+ def complete(tables: Tables, words: T.List[str], cur: str) -> Completion:
208
+ """Candidates for `cur` after `words` (without the program name). Not filtered by `cur`."""
209
+ state: "int | None" = 0
210
+ used: T.Set[int] = set()
211
+ expect: "Option | None" = None
212
+ positional_only = False
213
+
214
+ def find(name: str, prefix: bool) -> "int | None":
215
+ in_scope = tables.states[state].options if state is not None else []
216
+ matches = [i for i, o in enumerate(tables.options) if name in o.names]
217
+ if not matches and prefix:
218
+ matches = [i for i, o in enumerate(tables.options) if any(n.startswith(name) for n in o.names[:1])]
219
+ if len({tables.options[i].names[0] for i in matches}) > 1:
220
+ return None
221
+ return next((i for i in matches if i in in_scope), next(iter(matches), None))
222
+
223
+ for word in words:
224
+ if state is None:
225
+ return Completion()
226
+ if expect is not None:
227
+ expect = None
228
+ elif not positional_only and word == '--':
229
+ positional_only = True
230
+ state = tables.states[state].separator
231
+ elif not positional_only and word.startswith('--'):
232
+ name, eq, _ = word.partition('=')
233
+ if (i := find(name, True)) is not None:
234
+ used.add(i)
235
+ if tables.options[i].argument and not eq:
236
+ expect = tables.options[i]
237
+ elif not positional_only and word.startswith('-') and word != '-':
238
+ for pos in range(1, len(word)):
239
+ if (i := find('-' + word[pos], False)) is not None:
240
+ used.add(i)
241
+ if tables.options[i].argument:
242
+ if pos == len(word) - 1:
243
+ expect = tables.options[i]
244
+ break
245
+ else:
246
+ current = tables.states[state]
247
+ state = current.commands[word][0] if word in current.commands else current.other
248
+
249
+ if state is None:
250
+ return Completion()
251
+ if expect is not None:
252
+ return Completion(sources=expect.complete)
253
+ if not positional_only and cur.startswith('--') and '=' in cur:
254
+ name = cur.partition('=')[0]
255
+ if (i := find(name, True)) is not None and tables.options[i].argument:
256
+ return Completion(sources=tables.options[i].complete, prefix=name + '=')
257
+ return Completion()
258
+ current = tables.states[state]
259
+ options = Completion(words=[
260
+ (name, tables.options[i].description)
261
+ for i in current.options if i not in used or tables.options[i].repeatable
262
+ for name in tables.options[i].names
263
+ ])
264
+ if positional_only:
265
+ options = Completion()
266
+ if cur.startswith('-') and not positional_only:
267
+ return options
268
+ if not current.commands and current.other is None:
269
+ return options
270
+ return Completion(
271
+ words=[(name, description) for name, (_, description) in current.commands.items()],
272
+ sources=current.complete,
273
+ )
@@ -0,0 +1,40 @@
1
+ import shlex
2
+ from docopt_completion import tables as Tb, script
3
+
4
+ compsys = {
5
+ 'files': '_files',
6
+ 'dirs': '_files -/',
7
+ 'hosts': '_hosts',
8
+ 'users': '_users',
9
+ 'groups': '_groups',
10
+ 'commands': '_command_names -e',
11
+ 'signals': '_signals',
12
+ 'processes': '_pids',
13
+ }
14
+
15
+
16
+ def source_code(source: Tb.Source) -> str:
17
+ if isinstance(source, str):
18
+ return f'{compsys[source]} && ret=0'
19
+ if 'words' in source:
20
+ return 'compadd -- ' + ' '.join(shlex.quote(str(word)) for word in source['words']) + ' && ret=0'
21
+ return f'compadd -- ${{(f)"$({source["command"]})"}} && ret=0'
22
+
23
+
24
+ def generate(prog: str, compiled: Tb.Tables, refresh: "script.Refresh | None" = None) -> str:
25
+ # Named like the file compinit autoloads (_<prog>), so that the first run replaces the autoload stub
26
+ function = '_' + prog
27
+ code = ''
28
+ if refresh is not None:
29
+ binary, path, regenerate = (script.quote(refresh[0]), script.quote(refresh[1]), shlex.join(refresh[2]))
30
+ code = f'''# Regenerate this script when {prog} changed since
31
+ local bin=${{commands[{prog}]:A}}
32
+ if [[ -z $_docopt_refreshed && -n $bin && ( $bin != {binary} || $bin -nt {path} || $bin -ot {path} ) ]]; then
33
+ local _docopt_refreshed=1
34
+ if {regenerate} >/dev/null 2>&1; then
35
+ source {path}
36
+ {function} "$@"
37
+ return
38
+ fi
39
+ fi'''
40
+ return script.render('zsh.sh', prog, function, compiled, source_code, 1, True, code)
@@ -0,0 +1,119 @@
1
+ #compdef "PROG"
2
+ # zsh completion for "PROG", generated by docopt-completion
3
+
4
+ FN() {
5
+ "REFRESH"
6
+ "TABLES"
7
+ local state=1 expect=0 positional=0 word opt pos i n
8
+ local -a used
9
+ for word in "${(@Q)words[2,CURRENT-1]}"; do
10
+ if (( ! state )); then
11
+ return 1
12
+ elif (( expect )); then
13
+ expect=0
14
+ elif (( ! positional )) && [[ $word = -- ]]; then
15
+ positional=1
16
+ state=$st_sep[state]
17
+ elif (( ! positional )) && [[ $word = --* ]]; then
18
+ if HELPERS_find "${word%%=*}" 1; then
19
+ used+=($opt)
20
+ (( opt_arg[opt] )) && [[ $word != *=* ]] && expect=$opt
21
+ fi
22
+ elif (( ! positional )) && [[ $word = -?* ]]; then
23
+ for (( pos=2; pos<=$#word; pos++ )); do
24
+ if HELPERS_find "-$word[pos]" 0; then
25
+ used+=($opt)
26
+ if (( opt_arg[opt] )); then
27
+ (( pos == $#word )) && expect=$opt
28
+ break
29
+ fi
30
+ fi
31
+ done
32
+ else
33
+ pos=$st_other[state]
34
+ for (( i=1; i<=$#cmd_state; i++ )); do
35
+ if (( cmd_state[i] == state )) && [[ $cmd_name[i] = "$word" ]]; then
36
+ pos=$cmd_next[i]
37
+ break
38
+ fi
39
+ done
40
+ state=$pos
41
+ fi
42
+ done
43
+ (( state )) || return 1
44
+
45
+ if (( expect )); then
46
+ HELPERS_source $opt_src[expect]
47
+ return
48
+ elif (( ! positional )) && [[ $PREFIX = --*=* ]]; then
49
+ HELPERS_find "${PREFIX%%=*}" 1 && (( opt_arg[opt] )) || return 1
50
+ compset -P '*='
51
+ HELPERS_source $opt_src[opt]
52
+ return
53
+ fi
54
+ local -a options commands
55
+ if (( ! positional )); then
56
+ for i in ${=st_opts[state]}; do
57
+ (( $used[(Ie)$i] && ! opt_rep[i] )) && continue
58
+ for n in ${=opt_names[i]}; do
59
+ options+=("${n//:/\\:}${opt_desc[i]:+:$opt_desc[i]}")
60
+ done
61
+ done
62
+ fi
63
+ for (( i=1; i<=$#cmd_state; i++ )); do
64
+ (( cmd_state[i] == state )) && commands+=("${cmd_name[i]//:/\\:}${cmd_desc[i]:+:$cmd_desc[i]}")
65
+ done
66
+ if (( ! positional )) && [[ $PREFIX = -* ]] || (( ! $#commands && ! st_other[state] )); then
67
+ (( $#options )) && _describe -t options option options
68
+ return
69
+ fi
70
+ local ret=1
71
+ (( $#commands )) && _describe -t commands command commands && ret=0
72
+ HELPERS_source $st_src[state] && ret=0
73
+ return ret
74
+ }
75
+
76
+ # Sets $opt to the option named $1, matching long options by prefix when $2 is 1
77
+ HELPERS_find() {
78
+ local i n first
79
+ local -a matches
80
+ for (( i=1; i<=$#opt_names; i++ )); do
81
+ for n in ${=opt_names[i]}; do
82
+ [[ $n = "$1" ]] && matches+=($i)
83
+ done
84
+ done
85
+ if (( ! $#matches && $2 )); then
86
+ for (( i=1; i<=$#opt_names; i++ )); do
87
+ n=${${=opt_names[i]}[1]}
88
+ if [[ $n = "$1"* ]]; then
89
+ [[ -n $first && $first != "$n" ]] && return 1
90
+ first=$n
91
+ matches+=($i)
92
+ fi
93
+ done
94
+ fi
95
+ (( $#matches )) || return 1
96
+ opt=$matches[1]
97
+ for i in $matches; do
98
+ if (( ${${=st_opts[state]}[(Ie)$i]} )); then
99
+ opt=$i
100
+ return 0
101
+ fi
102
+ done
103
+ return 0
104
+ }
105
+
106
+ # Adds the candidates of source set $1
107
+ HELPERS_source() {
108
+ local ret=1
109
+ case $1 in
110
+ "SOURCES"
111
+ esac
112
+ return ret
113
+ }
114
+
115
+ if [[ $zsh_eval_context[-1] = loadautofunc ]]; then
116
+ FN "$@"
117
+ else
118
+ compdef FN "PROG"
119
+ fi
@@ -0,0 +1,6 @@
1
+ docopt.completion
2
+ =================
3
+
4
+ A completion script generator for [docopt](http://docopt.org/) compatible help texts.
5
+
6
+ The full documentation is available on [GitHub](https://github.com/andsens/docopt.completion#docoptcompletion).
@@ -0,0 +1,51 @@
1
+ [tool.poetry]
2
+ name = "docopt-completion"
3
+ description = "A completion for the docopt language"
4
+ version = "0.1.0"
5
+ authors = ["Anders Ingemann <anders@ingemann.de>"]
6
+ license = "MIT"
7
+ readme = "docs/README.pypi.md"
8
+ homepage = "https://github.com/andsens/docopt.completion"
9
+ repository = "https://github.com/andsens/docopt.completion"
10
+ classifiers = [
11
+ "Topic :: Software Development :: Libraries :: Python Modules",
12
+ "Programming Language :: Python :: 3",
13
+ "Programming Language :: Python :: 3.11",
14
+ "License :: OSI Approved :: MIT License",
15
+ ]
16
+ packages = [
17
+ { include = "docopt_completion" },
18
+ { include = "docopt_completion/py.typed" },
19
+ ]
20
+
21
+ [tool.flake8]
22
+ max-line-length = 120
23
+ ignore = "E111,E114,E121,W503,E302,E303,E305"
24
+
25
+ [tool.poetry.dependencies]
26
+ python = ">=3.11.0,<4.0"
27
+ docopt = "^0.6.2"
28
+ termcolor = "^1.1.0"
29
+ pyyaml = "^6.0"
30
+ docopt-parser = "^0.0.1"
31
+
32
+ [tool.poetry.dev-dependencies]
33
+ flake8 = "^6.0.0"
34
+ flake8-pyproject = "^1.1.0.post0"
35
+ pytest = "^7.1.1"
36
+ pyright = "^1.1.238"
37
+ types-docopt = "^0.6.11"
38
+
39
+ [tool.poetry.scripts]
40
+ docopt-completion = "docopt_completion.__main__:main"
41
+
42
+ [tool.pyright]
43
+ include = ["docopt_completion", "tests"]
44
+ typeCheckingMode = "strict"
45
+ exclude = [ ".venv" ]
46
+ venvPath = "."
47
+ venv = ".venv"
48
+
49
+ [build-system]
50
+ requires = ["poetry_core>=1.0.0"]
51
+ build-backend = "poetry.core.masonry.api"