File: application.py

package info (click to toggle)
crazy-complete 0.3.7-4
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 2,528 kB
  • sloc: python: 13,342; sh: 995; makefile: 68
file content (406 lines) | stat: -rw-r--r-- 12,607 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2025-2026 Benjamin Abendroth <braph93@gmx.de>

'''Class for main command line application.'''

import os
import sys
import shlex
import argparse

from .errors import CrazyError
from . import bash, fish, zsh
from . import argparse_source, json_source, yaml_source
from . import argparse_mod  # .complete()
from . import help_converter
from . import utils
from . import config
from . import paths


# The import of `argparse_mod` only modifies the classes provided by the
# argparse module. We use the dummy function to silence warnings about the
# unused import.
argparse_mod.dummy()


def boolean(string):
    '''Convert string to bool, else raise ValueError.'''

    try:
        return {'true': True, 'false': False}[string.lower()]
    except KeyError as e:
        raise ValueError(f"Not a bool: {string}") from e


def version(string):
    '''Parse a version.'''

    return tuple(map(int, string.split('.')))


def feature_list(string):
    '''Convert a comma separated string of features to list.'''

    features = string.split(',')

    for feature in features:
        if feature not in ('hidden', 'final', 'groups', 'repeatable', 'when'):
            raise ValueError(f"Invalid feature: {feature}")

    return features

_URL = "https://github.com/crazy-complete/crazy-complete"
_EPILOG = f"For complete documentation, see {_URL}"

p = argparse.ArgumentParser(
    'crazy-complete',
    description='Generate shell auto completion files for all major shells',
    epilog=_EPILOG,
    exit_on_error=False)

p.add_argument(
    'shell', choices=('bash', 'fish', 'zsh', 'json', 'yaml'),
    help='Specify the shell type for the completion script')

p.add_argument(
    'definition_file',
    help='The file containing the command line definitions'
).complete('file')

p.add_argument(
    '--version', action='version', version='%(prog)s 0.3.7',
    help='Show program version')

p.add_argument(
    '--manual', metavar='TOPIC', nargs='?',
    help='Show manual for topic')

p.add_argument(
    '--parser-variable', default=None,
    help='Specify the variable name of the ArgumentParser object (for --input-type=python)')

p.add_argument(
    '--input-type', choices=('yaml', 'json', 'python', 'help', 'auto'), default='auto',
    help='Specify input file type')

p.add_argument(
    '--abbreviate-commands', metavar='BOOL', default=False, type=boolean,
    help='Sets whether commands can be abbreviated'
).complete('choices', ('True', 'False'))

p.add_argument(
    '--abbreviate-options', metavar='BOOL', default=False, type=boolean,
    help='Sets whether options can be abbreviated'
).complete('choices', ('True', 'False'))

p.add_argument(
    '--repeatable-options', metavar='BOOL', default=False, type=boolean,
    help='Sets whether options are suggested multiple times during completion'
).complete('choices', ('True', 'False'))

p.add_argument(
    '--inherit-options', metavar='BOOL', default=False, type=boolean,
    help='Sets whether parent options are visible to subcommands'
).complete('choices', ('True', 'False'))

p.add_argument(
    '--option-stacking', metavar='BOOL', default=True, type=boolean,
    help='Sets whether short option stacking is allowed'
).complete('choices', ('True', 'False'))

p.add_argument(
    '--long-option-argument-separator', default='both',
    choices=('space', 'equals', 'both'),
    dest='long_opt_arg_sep',
    help='Sets which separators are used for delimiting a long option from its argument'
).complete('choices', ('space', 'equals', 'both'))

p.add_argument(
    '--disable', metavar='FEATURES', default=[], type=feature_list,
    help='Disable features (hidden,final,groups,repeatable,when)'
).complete('value_list', {'values': {
    'hidden':     'Disable hidden options',
    'final':      'Disable final options',
    'groups':     'Disable option groups',
    'repeatable': 'Disable check for repeatable options',
    'when':       'Disable conditional options and positionals',
}})

p.add_argument(
    '--vim-modeline', metavar='BOOL', default=True, type=boolean,
    help='Sets whether a vim modeline comment shall be appended to the generated code'
).complete('choices', ('True', 'False'))

p.add_argument(
    '--bash-completions-version', metavar='VERSION', default=(2,), type=version,
    help='Generate code for a specific bash-completions version'
)

p.add_argument(
    '--zsh-compdef', metavar='BOOL', default=True, type=boolean,
    help='Sets whether #compdef is used in zsh scripts'
).complete('choices', ('True', 'False'))

p.add_argument(
    '--fish-fast', metavar='BOOL', default=False, type=boolean,
    help='Use faster commandline parsing at the cost of correctness'
).complete('choices', ('True', 'False'))

p.add_argument(
    '--fish-inline-conditions', metavar='BOOL', default=False, type=boolean,
    help="Don't store conditions in a variable"
).complete('choices', ('True', 'False'))

p.add_argument(
    '--include-file', metavar='FILE', action='append',
    help='Include file in output'
).complete('file')

p.add_argument(
    '--comment', metavar='COMMENT', action='append',
    help='Add a comment to output'
)

p.add_argument(
    '--debug', action='store_true', default=False,
    help='Enable debug mode')

p.add_argument(
    '--keep-comments', action='store_true', default=False,
    help='Keep comments in generated output')

p.add_argument(
    '--max-line-length', default=80, type=int,
    help='Set the maximum line length of the generated output'
)

p.add_argument(
    '--function-prefix', metavar='PREFIX', default='_$PROG',
    help='Set the prefix used for generated functions')

grp = p.add_mutually_exclusive_group()

grp.add_argument(
    '-o', '--output', metavar='FILE', default=None, dest='output_file',
    help='Write output to destination file [default: stdout]'
).complete('file')

grp.add_argument(
    '-i', '--install-system-wide', default=False, action='store_true',
    help='Write output to the system-wide completions dir of shell')

grp.add_argument(
    '-u', '--uninstall-system-wide', default=False, action='store_true',
    help='Uninstall the system-wide completion file for program')

# We use a unique object name for avoiding name clashes when
# importing/executing the foreign python script
_crazy_complete_argument_parser = p
del p


def write_string_to_file(string, file):
    '''Writes string to file if file is given.'''

    if file is not None:
        with open(file, 'w', encoding='utf-8') as fh:
            fh.write(string)
    else:
        print(string)


def load_definition_file(opts):
    '''Load a definition file as specified in `opts`.'''

    if opts.input_type == 'auto':
        basename = os.path.basename(opts.definition_file)
        extension = os.path.splitext(basename)[1].lower().strip('.')

        if extension == 'json':
            return json_source.load_from_file(opts.definition_file)

        if extension in ('yaml', 'yml'):
            return yaml_source.load_from_file(opts.definition_file)

        if extension == 'py':
            msg = 'Reading Python files must be enabled by --input-type=python'
            raise CrazyError(msg)

        if extension == '':
            msg = ('File has no extension. '
                   'Please supply --input-type=json|yaml|help|python')
            raise CrazyError(msg)

        msg = (f'Unknown file extension `{extension}`. '
               'Please supply --input-type=json|yaml|help|python')
        raise CrazyError(msg)

    if opts.input_type == 'json':
        return json_source.load_from_file(opts.definition_file)

    if opts.input_type == 'yaml':
        return yaml_source.load_from_file(opts.definition_file)

    if opts.input_type == 'python':
        return argparse_source.load_from_file(
            opts.definition_file,
            opts.parser_variable,
            parser_blacklist=[_crazy_complete_argument_parser])

    raise AssertionError("Should not be reached")


def _get_config_from_options(opts):
    conf = config.Config()
    conf.set_debug(opts.debug)
    conf.set_function_prefix(opts.function_prefix)
    conf.set_abbreviate_commands(opts.abbreviate_commands)
    conf.set_abbreviate_options(opts.abbreviate_options)
    conf.set_repeatable_options(opts.repeatable_options)
    conf.set_inherit_options(opts.inherit_options)
    conf.set_option_stacking(opts.option_stacking)
    conf.set_long_option_argument_separator(opts.long_opt_arg_sep)
    conf.set_vim_modeline(opts.vim_modeline)
    conf.set_bash_completions_version(opts.bash_completions_version)
    conf.set_zsh_compdef(opts.zsh_compdef)
    conf.set_fish_fast(opts.fish_fast)
    conf.set_fish_inline_conditions(opts.fish_inline_conditions)
    conf.include_many_files(opts.include_file or [])
    conf.set_keep_comments(opts.keep_comments)
    conf.add_comments(opts.comment or [])
    conf.set_line_length(opts.max_line_length)

    for feature in opts.disable:
        if feature == 'hidden':
            conf.disable_hidden(True)
        elif feature == 'final':
            conf.disable_final(True)
        elif feature == 'groups':
            conf.disable_groups(True)
        elif feature == 'repeatable':
            conf.disable_repeatable(True)
        elif feature == 'when':
            conf.disable_when(True)

    return conf


def try_print_manual(args):
    '''Print manual.'''

    parser = argparse.ArgumentParser(add_help=False)
    parser.add_argument('--manual', nargs='?', const='all')
    opts, _ = parser.parse_known_args(args)

    if not opts.manual:
        return

    from . import manual
    manual.print_help_topic(opts.manual, os.isatty(sys.stdout.fileno()))
    sys.exit(0)


def try_batch(args):
    '''Do batch processing.'''

    parser = argparse.ArgumentParser(add_help=False)
    parser.add_argument('--batch')
    opts, _ = parser.parse_known_args(args)

    if not opts.batch:
        return

    with open(opts.batch, 'r', encoding='utf-8') as fh:
        content = fh.read()

    for line in content.split('\n'):
        line = line.strip()

        if not line or line.startswith('#'):
            continue

        utils.print_err('Running:', sys.argv[0], line)

        app = Application()
        app.parse_args(shlex.split(line))
        app.run()

    sys.exit(0)


def generate(opts):
    '''Generate output file as specified in `opts`.'''

    if opts.input_type == 'help':
        if opts.shell != 'yaml':
            raise CrazyError('The `help` input-type currently only supports YAML generation')
        output = help_converter.from_file_to_yaml(opts.definition_file)
        write_string_to_file(output, opts.output_file)
        return

    cmdline = load_definition_file(opts)

    if opts.shell == 'json':
        output = json_source.commandline_to_json(cmdline)
        write_string_to_file(output, opts.output_file)
        return

    if opts.shell == 'yaml':
        output = yaml_source.commandline_to_yaml(cmdline)
        write_string_to_file(output, opts.output_file)
        return

    conf = _get_config_from_options(opts)

    if opts.shell == 'bash':
        output = bash.generate_completion(cmdline, conf)
    elif opts.shell == 'fish':
        output = fish.generate_completion(cmdline, conf)
    elif opts.shell == 'zsh':
        output = zsh.generate_completion(cmdline, conf)

    if opts.install_system_wide or opts.uninstall_system_wide:
        file = {
            'bash':  paths.get_bash_completion_file,
            'fish':  paths.get_fish_completion_file,
            'zsh':   paths.get_zsh_completion_file,
        }[opts.shell](cmdline.prog)

        if opts.install_system_wide:
            utils.print_err(f'Installing to {file}')
            write_string_to_file(output, file)
        else:
            utils.print_err(f'Removing {file}')
            os.remove(file)
    else:
        write_string_to_file(output, opts.output_file)


class Application:
    '''Class for main command line application.'''

    def __init__(self):
        self.options = None

    def parse_args(self, args):
        '''Parse command line arguments.

        Raises:
            - argparse.ArgumentError
        '''
        try_print_manual(args)
        try_batch(args)
        self.options = _crazy_complete_argument_parser.parse_args(args)

    def run(self):
        '''Run the crazy-complete program.

        Raises:
            - CrazyError
            - FileNotFoundError
            - yaml.scanner.ScannerError
            - yaml.parser.ParserError
            - json.decoder.JSONDecodeError
        '''
        generate(self.options)