Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/face/helpers.py: 25%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1import os
2import sys
3import array
4import textwrap
6from boltons.iterutils import unique, split
8from face.utils import format_flag_label, format_flag_post_doc, format_posargs_label, echo
9from face.parser import Flag
11DEFAULT_HELP_FLAG = Flag('--help', parse_as=True, char='-h', doc='show this help message and exit')
12DEFAULT_MAX_WIDTH = 120
15def _get_termios_winsize():
16 # TLPI, 62.9 (p. 1319)
17 import fcntl
18 import termios
20 winsize = array.array('H', [0, 0, 0, 0])
22 assert not fcntl.ioctl(sys.stdout, termios.TIOCGWINSZ, winsize)
24 ws_row, ws_col, _, _ = winsize
26 return ws_row, ws_col
29def _get_environ_winsize():
30 # the argparse approach. not sure which systems this works or
31 # worked on, if any. ROWS/COLUMNS are special shell variables.
32 try:
33 rows, columns = int(os.environ['ROWS']), int(os.environ['COLUMNS'])
34 except (KeyError, ValueError):
35 rows, columns = None, None
36 return rows, columns
39def get_winsize():
40 rows, cols = None, None
41 try:
42 rows, cols = _get_termios_winsize()
43 except Exception:
44 try:
45 rows, cols = _get_environ_winsize()
46 except Exception:
47 pass
48 return rows, cols
51def get_wrap_width(max_width=DEFAULT_MAX_WIDTH):
52 _, width = get_winsize()
53 if width is None:
54 width = 80
55 width = min(width, max_width)
56 width -= 2
57 return width
60def _wrap_stout_pair(indent, label, sep, doc, doc_start, max_doc_width):
61 # TODO: consider making the fill character configurable (ljust
62 # uses space by default, the just() methods can only take
63 # characters, might be a useful bolton to take a repeating
64 # sequence)
65 ret = []
66 append = ret.append
67 lhs = indent + label
69 if not doc:
70 append(lhs)
71 return ret
73 len_sep = len(sep)
74 wrapped_doc = textwrap.wrap(doc, max_doc_width)
75 if len(lhs) <= doc_start:
76 lhs_f = lhs.ljust(doc_start - len(sep)) + sep
77 append(lhs_f + wrapped_doc[0])
78 else:
79 append(lhs)
80 append((' ' * (doc_start - len_sep)) + sep + wrapped_doc[0])
82 for line in wrapped_doc[1:]:
83 append(' ' * doc_start + line)
85 return ret
88def _wrap_stout_cmd_doc(indent, doc, max_width):
89 """Function for wrapping command description."""
90 parts = []
91 paras = ['\n'.join(para) for para in
92 split(doc.splitlines(), lambda l: not l.lstrip())
93 if para]
94 for para in paras:
95 part = textwrap.fill(text=para,
96 width=(max_width - len(indent)),
97 initial_indent=indent,
98 subsequent_indent=indent)
99 parts.append(part)
100 return '\n\n'.join(parts)
103def get_stout_layout(labels, indent, sep, width=None, max_width=DEFAULT_MAX_WIDTH,
104 min_doc_width=40):
105 width = width or get_wrap_width(max_width=max_width)
107 len_sep = len(sep)
108 len_indent = len(indent)
110 max_label_width = 0
111 max_doc_width = min_doc_width
112 doc_start = width - min_doc_width
113 for label in labels:
114 cur_len = len(label)
115 if cur_len < max_label_width:
116 continue
117 max_label_width = cur_len
118 if (len_indent + cur_len + len_sep + min_doc_width) < width:
119 max_doc_width = width - max_label_width - len_sep - len_indent
120 doc_start = len_indent + cur_len + len_sep
122 return {'width': width,
123 'label_width': max_label_width,
124 'doc_width': max_doc_width,
125 'doc_start': doc_start}
128DEFAULT_CONTEXT = {
129 'usage_label': 'Usage:',
130 'subcmd_section_heading': 'Subcommands: ',
131 'flags_section_heading': 'Flags: ',
132 'posargs_section_heading': 'Positional arguments:',
133 'section_break': '\n',
134 'group_break': '',
135 'subcmd_example': 'subcommand',
136 'width': None,
137 'max_width': 120,
138 'min_doc_width': 50,
139 'format_posargs_label': format_posargs_label,
140 'format_flag_label': format_flag_label,
141 'format_flag_post_doc': format_flag_post_doc,
142 'doc_separator': ' ', # ' + ' is pretty classy as bullet points, too
143 'section_indent': ' ',
144 'pre_doc': '', # TODO: these should go on CommandDisplay
145 'post_doc': '\n',
146}
149class StoutHelpFormatter:
150 """This formatter takes :class:`Parser` and :class:`Command` instances
151 and generates help text. The output style is inspired by, but not
152 the same as, argparse's automatic help formatting.
154 Probably what most Pythonists expect, this help text is slightly
155 stouter (conservative with vertical space) than other conventional
156 help messages.
158 The default output looks like::
160 Usage: example.py subcommand [FLAGS]
162 Does a bit of busy work
165 Subcommands:
167 sum Just a lil fun in the sum
168 subtract
169 print
172 Flags:
174 --help / -h show this help message and exit
175 --verbose / -V
178 Due to customizability, the constructor takes a large number of
179 keyword arguments, the most important of which are highlighted
180 here.
182 Args:
183 width (int): The width of the help output in
184 columns/characters. Defaults to the width of the terminal,
185 with a max of *max_width*.
186 max_width (int): The widest the help output will get. Too wide
187 and it can be hard to visually scan. Defaults to 120 columns.
188 min_doc_width (int): The text documentation's minimum width in
189 columns/characters. Puts flags and subcommands on their own
190 lines when they're long or the terminal is narrow. Defaults to
191 50.
192 doc_separator (str): The string to put between a
193 flag/subcommand and its documentation. Defaults to `' '`. (Try
194 `' + '` for a classy bulleted doc style.
196 An instance of StoutHelpFormatter can be passed to
197 :class:`HelpHandler`, which can in turn be passed to
198 :class:`Command` for maximum command customizability.
200 Alternatively, when using :class:`Parser` object directly, you can
201 instantiate this type and pass a :class:`Parser` object to
202 :meth:`get_help_text()` or :meth:`get_usage_line()` to get
203 identically formatted text without sacrificing flow control.
205 HelpFormatters are stateless, in that they can be used more than
206 once, with different Parsers and Commands without needing to be
207 recreated or otherwise reset.
209 """
210 default_context = dict(DEFAULT_CONTEXT)
212 def __init__(self, **kwargs):
213 self.ctx = {}
214 for key, val in self.default_context.items():
215 self.ctx[key] = kwargs.pop(key, val)
216 if kwargs:
217 raise TypeError(f'unexpected formatter arguments: {list(kwargs.keys())!r}')
219 def _get_layout(self, labels):
220 ctx = self.ctx
221 return get_stout_layout(labels=labels,
222 indent=ctx['section_indent'],
223 sep=ctx['doc_separator'],
224 width=ctx['width'],
225 max_width=ctx['max_width'],
226 min_doc_width=ctx['min_doc_width'])
228 def get_help_text(self, parser, subcmds=(), program_name=None):
229 """Turn a :class:`Parser` or :class:`Command` into a multiline
230 formatted help string, suitable for printing. Includes the
231 usage line and trailing newline by default.
233 Args:
234 parser (Parser): A :class:`Parser` or :class:`Command`
235 object to generate help text for.
236 subcmds (tuple): A sequence of subcommand strings
237 specifying the subcommand to generate help text for.
238 Defaults to ``()``.
239 program_name (str): The program name, if it differs from
240 the default ``sys.argv[0]``. (For example,
241 ``example.py``, when running the command ``python
242 example.py --flag val arg``.)
244 """
245 # TODO: incorporate "Arguments" section if posargs has a doc set
246 ctx = self.ctx
248 ret = [self.get_usage_line(parser, subcmds=subcmds, program_name=program_name)]
249 append = ret.append
250 append(ctx['group_break'])
252 shown_flags = parser.get_flags(path=subcmds, with_hidden=False)
253 if subcmds:
254 parser = parser.subprs_map[subcmds]
256 if parser.doc:
257 append(_wrap_stout_cmd_doc(indent=ctx['section_indent'],
258 doc=parser.doc,
259 max_width=ctx['width'] or get_wrap_width(
260 max_width=ctx['max_width'])))
261 append(ctx['section_break'])
263 if parser.subprs_map:
264 subcmd_names = unique([sp[0] for sp in parser.subprs_map if sp])
265 subcmd_layout = self._get_layout(labels=subcmd_names)
267 append(ctx['subcmd_section_heading'])
268 append(ctx['group_break'])
269 for sub_name in unique([sp[0] for sp in parser.subprs_map if sp]):
270 subprs = parser.subprs_map[(sub_name,)]
271 # TODO: sub_name.replace('_', '-') = _cmd -> -cmd (need to skip replacing leading underscores)
272 subcmd_lines = _wrap_stout_pair(indent=ctx['section_indent'],
273 label=sub_name.replace('_', '-'),
274 sep=ctx['doc_separator'],
275 doc=subprs.doc,
276 doc_start=subcmd_layout['doc_start'],
277 max_doc_width=subcmd_layout['doc_width'])
278 ret.extend(subcmd_lines)
280 append(ctx['section_break'])
282 if not shown_flags:
283 return '\n'.join(ret)
285 fmt_flag_label = ctx['format_flag_label']
286 flag_labels = [fmt_flag_label(flag) for flag in shown_flags]
287 flag_layout = self._get_layout(labels=flag_labels)
289 fmt_flag_post_doc = ctx['format_flag_post_doc']
290 append(ctx['flags_section_heading'])
291 append(ctx['group_break'])
292 for flag in shown_flags:
293 disp = flag.display
294 if disp.full_doc is not None:
295 doc = disp.full_doc
296 else:
297 _parts = [disp.doc] if disp.doc else []
298 post_doc = disp.post_doc if disp.post_doc else fmt_flag_post_doc(flag)
299 if post_doc:
300 _parts.append(post_doc)
301 doc = ' '.join(_parts)
303 flag_lines = _wrap_stout_pair(indent=ctx['section_indent'],
304 label=fmt_flag_label(flag),
305 sep=ctx['doc_separator'],
306 doc=doc,
307 doc_start=flag_layout['doc_start'],
308 max_doc_width=flag_layout['doc_width'])
310 ret.extend(flag_lines)
312 return ctx['pre_doc'] + '\n'.join(ret) + ctx['post_doc']
314 def get_usage_line(self, parser, subcmds=(), program_name=None):
315 """Get just the top line of automated text output. Arguments are the
316 same as :meth:`get_help_text()`. Basic info about running the
317 command, such as:
319 Usage: example.py subcommand [FLAGS] [args ...]
321 """
322 ctx = self.ctx
323 subcmds = tuple(subcmds or ())
324 parts = [ctx['usage_label']] if ctx['usage_label'] else []
325 append = parts.append
327 program_name = program_name or parser.name
329 append(' '.join((program_name,) + subcmds))
331 # TODO: put () in subprs_map to handle some of this sorta thing
332 if not subcmds and parser.subprs_map:
333 append('subcommand')
334 elif subcmds and parser.subprs_map[subcmds].subprs_map:
335 append('subcommand')
337 # with subcommands out of the way, look up the parser for flags and args
338 if subcmds:
339 parser = parser.subprs_map[subcmds]
341 flags = parser.get_flags(with_hidden=False)
343 if flags:
344 append('[FLAGS]')
346 if not parser.posargs.display.hidden:
347 fmt_posargs_label = ctx['format_posargs_label']
348 append(fmt_posargs_label(parser.posargs))
350 return ' '.join(parts)
354'''
355class AiryHelpFormatter(object):
356 """No wrapping a doc onto the same line as the label. Just left
357 aligned labels + newline, then right align doc. No complicated
358 width calculations either. See https://github.com/kbknapp/clap-rs
359 """
360 pass # TBI
361'''
364class HelpHandler:
365 """The HelpHandler is a one-stop object for that all-important CLI
366 feature: automatic help generation. It ties together the actual
367 help handler with the optional flag and subcommand such that it
368 can be added to any :class:`Command` instance.
370 The :class:`Command` creates a HelpHandler instance by default,
371 and its constructor also accepts an instance of this type to
372 customize a variety of helpful features.
374 Args:
375 flag (face.Flag): The Flag instance to use for triggering a
376 help output in a Command setting. Defaults to the standard
377 ``--help / -h`` flag. Pass ``False`` to disable.
378 subcmd (str): A subcommand name to be added to any
379 :class:`Command` using this HelpHandler. Defaults to
380 ``None``.
381 formatter: A help formatter instance or type. Type will be
382 instantiated with keyword arguments passed to this
383 constructor. Defaults to :class:`StoutHelpFormatter`.
384 func (callable): The actual handler function called on flag
385 presence or subcommand invocation. Defaults to
386 :meth:`HelpHandler.default_help_func()`.
388 All other remaining keyword arguments are used to construct the
389 HelpFormatter, if *formatter* is a type (as is the default). For
390 an example of a formatter, see :class:`StoutHelpFormatter`, the
391 default help formatter.
392 """
393 # Other hooks (besides the help function itself):
394 # * Callbacks for unhandled exceptions
395 # * Callbacks for formatting errors (add a "see --help for more options")
397 def __init__(self, flag=DEFAULT_HELP_FLAG, subcmd=None,
398 formatter=StoutHelpFormatter, func=None, **formatter_kwargs):
399 # subcmd expects a string
400 self.flag = flag
401 self.subcmd = subcmd
402 self.func = func if func is not None else self.default_help_func
403 if not callable(self.func):
404 raise TypeError(f'expected help handler func to be callable, not {func!r}')
406 self.formatter = formatter
407 if not formatter:
408 raise TypeError(f'expected Formatter type or instance, not: {formatter!r}')
409 if isinstance(formatter, type):
410 self.formatter = formatter(**formatter_kwargs)
411 elif formatter_kwargs:
412 raise TypeError('only accepts extra formatter kwargs (%r) if'
413 ' formatter argument is a Formatter type, not: %r'
414 % (sorted(formatter_kwargs.keys()), formatter))
415 _has_get_help_text = callable(getattr(self.formatter, 'get_help_text', None))
416 if not _has_get_help_text:
417 raise TypeError('expected valid formatter, or other object with a'
418 ' get_help_text() method, not %r' % (self.formatter,))
419 return
421 def default_help_func(self, cmd_, subcmds_, args_, command_):
422 """The default help handler function. Called when either the help flag
423 or subcommand is passed.
425 Prints the output of the help formatter instance attached to
426 this HelpHandler and exits with exit code 0.
428 """
429 echo(self.formatter.get_help_text(command_, subcmds=subcmds_, program_name=cmd_))
432"""Usage: cmd_name sub_cmd [..as many subcommands as the max] --flags args ...
434Possible commands:
436(One of the possible styles below)
438Flags:
439 Group name (if grouped):
440 -F, --flag VALUE Help text goes here. (integer, defaults to 3)
442Flag help notes:
444* don't display parenthetical if it's string/None
445* Also need to indicate required and mutual exclusion ("not with")
446* Maybe experimental / deprecated support
447* General flag listing should also include flags up the chain
449Subcommand listing styles:
451* Grouped, one-deep, flag overview on each
452* One-deep, grouped or alphabetical, help string next to each
453* Grouped by tree (new group whenever a subtree of more than one
454 member finishes), with help next to each.
456What about extra lines in the help (like zfs) (maybe each individual
457line can be a template string?)
459TODO: does face need built-in support for version subcommand/flag,
460basically identical to help?
462Group names can be ints or strings. When, group names are strings,
463flags are indented under a heading consisting of the string followed
464by a colon. All ungrouped flags go under a 'General Flags' group
465name. When group names are ints, groups are not indented, but a
466newline is still emitted by each group.
468Alphabetize should be an option, otherwise everything stays in
469insertion order.
471Subcommands without handlers should not be displayed in help. Also,
472their implicit handler prints the help.
474Subcommand groups could be Commands with name='', and they can only be
475added to other commands, where they would embed as siblings instead of
476as subcommands. Similar to how clastic subapplications can be mounted
477without necessarily adding to the path.
479Is it better to delegate representations out or keep them all within
480the help builder?
482---
484Help needs: a flag (and a way to disable it), as well as a renderer.
486Usage:
488Doc
490Subcommands:
492... ...
494Flags:
496...
498Postdoc
501{usage_label} {cmd_name} {subcmd_path} {subcmd_blank} {flags_blank} {posargs_label}
503{cmd.doc}
505{subcmd_heading}
507 {subcmd.name} {subcmd.doc} {subcmd.post_doc}
509{flags_heading}
511 {group_name}:
513 {flag_label} {flag.doc} {flag.post_doc}
515{cmd.post_doc}
518--------
520# Grouping
522Effectively sorted on: (group_name, group_index, sort_order, label)
524But group names should be based on insertion order, with the
525default-grouped/ungrouped items showing up in the last group.
527# Wrapping / Alignment
529Docs start at the position after the longest "left-hand side"
530(LHS/"key") item that would not cause the first line of the docs to be
531narrower than the minimum doc width.
533LHSes which do extend beyond this point will be on their own line,
534with the doc starting on the line below.
536# Window width considerations
538With better termios-based logic in place to get window size, there are
539going to be a lot of wider-than-80-char help messages.
541The goal of help message alignment is to help eyes track across from a
542flag or subcommand to its corresponding doc. Rather than maximizing
543width usage or topping out at a max width limit, we should be
544balancing or at least limiting the amount of whitespace between the
545shortest flag and its doc. (TODO)
547A width limit might still make sense because reading all the way
548across the screen can be tiresome, too.
550TODO: padding_top and padding_bottom attributes on various displays
551(esp FlagDisplay) to enable finer grained whitespace control without
552complicated group setups.
554"""