Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/face/helpers.py: 22%

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

199 statements  

1import os 

2import sys 

3import array 

4import textwrap 

5 

6from boltons.iterutils import unique, split 

7 

8from face.utils import format_flag_label, format_flag_post_doc, format_posargs_label, echo 

9from face.parser import Flag 

10 

11DEFAULT_HELP_FLAG = Flag('--help', parse_as=True, char='-h', doc='show this help message and exit') 

12DEFAULT_MAX_WIDTH = 120 

13 

14 

15def _get_termios_winsize(): 

16 # TLPI, 62.9 (p. 1319) 

17 import fcntl 

18 import termios 

19 

20 winsize = array.array('H', [0, 0, 0, 0]) 

21 

22 assert not fcntl.ioctl(sys.stdout, termios.TIOCGWINSZ, winsize) 

23 

24 ws_row, ws_col, _, _ = winsize 

25 

26 return ws_row, ws_col 

27 

28 

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 

37 

38 

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 

49 

50 

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 

58 

59 

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 

68 

69 if not doc: 

70 append(lhs) 

71 return ret 

72 

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]) 

81 

82 for line in wrapped_doc[1:]: 

83 append(' ' * doc_start + line) 

84 

85 return ret 

86 

87 

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) 

101 

102 

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) 

106 

107 len_sep = len(sep) 

108 len_indent = len(indent) 

109 

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 

121 

122 return {'width': width, 

123 'label_width': max_label_width, 

124 'doc_width': max_doc_width, 

125 'doc_start': doc_start} 

126 

127 

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} 

147 

148 

149def _get_subcmd_groups(parser): 

150 """Return a dict mapping group key -> list of (sub_name, subprs). 

151 

152 Group key is a string (named group), int (unnamed visual break), 

153 or None (ungrouped). Insertion order preserved: first occurrence 

154 of a group determines its position. None-group (ungrouped) is 

155 always sorted first regardless of insertion order. 

156 """ 

157 groups = {} 

158 for sub_name in unique([sp[0] for sp in parser.subprs_map if sp]): 

159 subprs = parser.subprs_map[(sub_name,)] 

160 group = subprs.group 

161 groups.setdefault(group, []).append((sub_name, subprs)) 

162 

163 # Move ungrouped (None) to front 

164 if None in groups: 

165 ungrouped = groups.pop(None) 

166 result = {None: ungrouped} 

167 result.update(groups) 

168 return result 

169 return groups 

170 

171 

172class StoutHelpFormatter: 

173 """This formatter takes :class:`Parser` and :class:`Command` instances 

174 and generates help text. The output style is inspired by, but not 

175 the same as, argparse's automatic help formatting. 

176 

177 Probably what most Pythonists expect, this help text is slightly 

178 stouter (conservative with vertical space) than other conventional 

179 help messages. 

180 

181 The default output looks like:: 

182 

183 Usage: example.py subcommand [FLAGS] 

184 

185 Does a bit of busy work 

186 

187 

188 Subcommands: 

189 

190 sum Just a lil fun in the sum 

191 subtract 

192 print 

193 

194 

195 Flags: 

196 

197 --help / -h show this help message and exit 

198 --verbose / -V 

199 

200 

201 Due to customizability, the constructor takes a large number of 

202 keyword arguments, the most important of which are highlighted 

203 here. 

204 

205 Args: 

206 width (int): The width of the help output in 

207 columns/characters. Defaults to the width of the terminal, 

208 with a max of *max_width*. 

209 max_width (int): The widest the help output will get. Too wide 

210 and it can be hard to visually scan. Defaults to 120 columns. 

211 min_doc_width (int): The text documentation's minimum width in 

212 columns/characters. Puts flags and subcommands on their own 

213 lines when they're long or the terminal is narrow. Defaults to 

214 50. 

215 doc_separator (str): The string to put between a 

216 flag/subcommand and its documentation. Defaults to `' '`. (Try 

217 `' + '` for a classy bulleted doc style. 

218 

219 An instance of StoutHelpFormatter can be passed to 

220 :class:`HelpHandler`, which can in turn be passed to 

221 :class:`Command` for maximum command customizability. 

222 

223 Alternatively, when using :class:`Parser` object directly, you can 

224 instantiate this type and pass a :class:`Parser` object to 

225 :meth:`get_help_text()` or :meth:`get_usage_line()` to get 

226 identically formatted text without sacrificing flow control. 

227 

228 HelpFormatters are stateless, in that they can be used more than 

229 once, with different Parsers and Commands without needing to be 

230 recreated or otherwise reset. 

231 

232 """ 

233 default_context = dict(DEFAULT_CONTEXT) 

234 

235 def __init__(self, **kwargs): 

236 self.ctx = {} 

237 for key, val in self.default_context.items(): 

238 self.ctx[key] = kwargs.pop(key, val) 

239 if kwargs: 

240 raise TypeError(f'unexpected formatter arguments: {list(kwargs.keys())!r}') 

241 

242 def _get_layout(self, labels): 

243 ctx = self.ctx 

244 return get_stout_layout(labels=labels, 

245 indent=ctx['section_indent'], 

246 sep=ctx['doc_separator'], 

247 width=ctx['width'], 

248 max_width=ctx['max_width'], 

249 min_doc_width=ctx['min_doc_width']) 

250 

251 def get_help_text(self, parser, subcmds=(), program_name=None): 

252 """Turn a :class:`Parser` or :class:`Command` into a multiline 

253 formatted help string, suitable for printing. Includes the 

254 usage line and trailing newline by default. 

255 

256 Args: 

257 parser (Parser): A :class:`Parser` or :class:`Command` 

258 object to generate help text for. 

259 subcmds (tuple): A sequence of subcommand strings 

260 specifying the subcommand to generate help text for. 

261 Defaults to ``()``. 

262 program_name (str): The program name, if it differs from 

263 the default ``sys.argv[0]``. (For example, 

264 ``example.py``, when running the command ``python 

265 example.py --flag val arg``.) 

266 

267 """ 

268 # TODO: incorporate "Arguments" section if posargs has a doc set 

269 ctx = self.ctx 

270 

271 ret = [self.get_usage_line(parser, subcmds=subcmds, program_name=program_name)] 

272 append = ret.append 

273 append(ctx['group_break']) 

274 

275 shown_flags = parser.get_flags(path=subcmds, with_hidden=False) 

276 if subcmds: 

277 parser = parser.subprs_map[subcmds] 

278 

279 if parser.doc: 

280 append(_wrap_stout_cmd_doc(indent=ctx['section_indent'], 

281 doc=parser.doc, 

282 max_width=ctx['width'] or get_wrap_width( 

283 max_width=ctx['max_width']))) 

284 append(ctx['section_break']) 

285 

286 if parser.subprs_map: 

287 subcmd_groups = _get_subcmd_groups(parser) 

288 has_named_groups = any(isinstance(g, str) for g in subcmd_groups) 

289 

290 # Compute layout across ALL subcommands for consistent doc-column alignment. 

291 all_subcmd_names = [n.replace('_', '-') for n in 

292 unique([sp[0] for sp in parser.subprs_map if sp])] 

293 if has_named_groups: 

294 subcmd_layout = get_stout_layout( 

295 labels=all_subcmd_names, 

296 indent=ctx['section_indent'] * 2, 

297 sep=ctx['doc_separator'], 

298 width=ctx['width'], 

299 max_width=ctx['max_width'], 

300 min_doc_width=ctx['min_doc_width']) 

301 else: 

302 subcmd_layout = self._get_layout(labels=all_subcmd_names) 

303 

304 append(ctx['subcmd_section_heading']) 

305 append(ctx['group_break']) 

306 

307 first_group = True 

308 for group_key, group_subcmds in subcmd_groups.items(): 

309 if not first_group: 

310 append('') # blank line between groups 

311 first_group = False 

312 

313 if isinstance(group_key, str): 

314 append(ctx['section_indent'] + group_key + ':') 

315 

316 # Ungrouped items use section_indent; grouped items use double indent 

317 if has_named_groups and isinstance(group_key, str): 

318 item_indent = ctx['section_indent'] * 2 

319 else: 

320 item_indent = ctx['section_indent'] 

321 

322 for sub_name, subprs in group_subcmds: 

323 subcmd_lines = _wrap_stout_pair( 

324 indent=item_indent, 

325 label=sub_name.replace('_', '-'), 

326 sep=ctx['doc_separator'], 

327 doc=subprs.doc, 

328 doc_start=subcmd_layout['doc_start'], 

329 max_doc_width=subcmd_layout['doc_width']) 

330 ret.extend(subcmd_lines) 

331 

332 append(ctx['section_break']) 

333 

334 if not shown_flags: 

335 return '\n'.join(ret) 

336 

337 fmt_flag_label = ctx['format_flag_label'] 

338 flag_labels = [fmt_flag_label(flag) for flag in shown_flags] 

339 flag_layout = self._get_layout(labels=flag_labels) 

340 

341 fmt_flag_post_doc = ctx['format_flag_post_doc'] 

342 append(ctx['flags_section_heading']) 

343 append(ctx['group_break']) 

344 for flag in shown_flags: 

345 disp = flag.display 

346 if disp.full_doc is not None: 

347 doc = disp.full_doc 

348 else: 

349 _parts = [disp.doc] if disp.doc else [] 

350 post_doc = disp.post_doc if disp.post_doc else fmt_flag_post_doc(flag) 

351 if post_doc: 

352 _parts.append(post_doc) 

353 doc = ' '.join(_parts) 

354 

355 flag_lines = _wrap_stout_pair(indent=ctx['section_indent'], 

356 label=fmt_flag_label(flag), 

357 sep=ctx['doc_separator'], 

358 doc=doc, 

359 doc_start=flag_layout['doc_start'], 

360 max_doc_width=flag_layout['doc_width']) 

361 

362 ret.extend(flag_lines) 

363 

364 return ctx['pre_doc'] + '\n'.join(ret) + ctx['post_doc'] 

365 

366 def get_usage_line(self, parser, subcmds=(), program_name=None): 

367 """Get just the top line of automated text output. Arguments are the 

368 same as :meth:`get_help_text()`. Basic info about running the 

369 command, such as: 

370 

371 Usage: example.py subcommand [FLAGS] [args ...] 

372 

373 """ 

374 ctx = self.ctx 

375 subcmds = tuple(subcmds or ()) 

376 parts = [ctx['usage_label']] if ctx['usage_label'] else [] 

377 append = parts.append 

378 

379 program_name = program_name or parser.name 

380 

381 append(' '.join((program_name,) + subcmds)) 

382 

383 # TODO: put () in subprs_map to handle some of this sorta thing 

384 if not subcmds and parser.subprs_map: 

385 append('subcommand') 

386 elif subcmds and parser.subprs_map[subcmds].subprs_map: 

387 append('subcommand') 

388 

389 # with subcommands out of the way, look up the parser for flags and args 

390 if subcmds: 

391 parser = parser.subprs_map[subcmds] 

392 

393 flags = parser.get_flags(with_hidden=False) 

394 

395 if flags: 

396 append('[FLAGS]') 

397 

398 if not parser.posargs.display.hidden: 

399 fmt_posargs_label = ctx['format_posargs_label'] 

400 append(fmt_posargs_label(parser.posargs)) 

401 

402 return ' '.join(parts) 

403 

404 

405 

406''' 

407class AiryHelpFormatter(object): 

408 """No wrapping a doc onto the same line as the label. Just left 

409 aligned labels + newline, then right align doc. No complicated 

410 width calculations either. See https://github.com/kbknapp/clap-rs 

411 """ 

412 pass # TBI 

413''' 

414 

415 

416class HelpHandler: 

417 """The HelpHandler is a one-stop object for that all-important CLI 

418 feature: automatic help generation. It ties together the actual 

419 help handler with the optional flag and subcommand such that it 

420 can be added to any :class:`Command` instance. 

421 

422 The :class:`Command` creates a HelpHandler instance by default, 

423 and its constructor also accepts an instance of this type to 

424 customize a variety of helpful features. 

425 

426 Args: 

427 flag (face.Flag): The Flag instance to use for triggering a 

428 help output in a Command setting. Defaults to the standard 

429 ``--help / -h`` flag. Pass ``False`` to disable. 

430 subcmd (str): A subcommand name to be added to any 

431 :class:`Command` using this HelpHandler. Defaults to 

432 ``None``. 

433 formatter: A help formatter instance or type. Type will be 

434 instantiated with keyword arguments passed to this 

435 constructor. Defaults to :class:`StoutHelpFormatter`. 

436 func (callable): The actual handler function called on flag 

437 presence or subcommand invocation. Defaults to 

438 :meth:`HelpHandler.default_help_func()`. 

439 

440 All other remaining keyword arguments are used to construct the 

441 HelpFormatter, if *formatter* is a type (as is the default). For 

442 an example of a formatter, see :class:`StoutHelpFormatter`, the 

443 default help formatter. 

444 """ 

445 # Other hooks (besides the help function itself): 

446 # * Callbacks for unhandled exceptions 

447 # * Callbacks for formatting errors (add a "see --help for more options") 

448 

449 def __init__(self, flag=DEFAULT_HELP_FLAG, subcmd=None, 

450 formatter=StoutHelpFormatter, func=None, **formatter_kwargs): 

451 # subcmd expects a string 

452 self.flag = flag 

453 self.subcmd = subcmd 

454 self.func = func if func is not None else self.default_help_func 

455 if not callable(self.func): 

456 raise TypeError(f'expected help handler func to be callable, not {func!r}') 

457 

458 self.formatter = formatter 

459 if not formatter: 

460 raise TypeError(f'expected Formatter type or instance, not: {formatter!r}') 

461 if isinstance(formatter, type): 

462 self.formatter = formatter(**formatter_kwargs) 

463 elif formatter_kwargs: 

464 raise TypeError('only accepts extra formatter kwargs (%r) if' 

465 ' formatter argument is a Formatter type, not: %r' 

466 % (sorted(formatter_kwargs.keys()), formatter)) 

467 _has_get_help_text = callable(getattr(self.formatter, 'get_help_text', None)) 

468 if not _has_get_help_text: 

469 raise TypeError('expected valid formatter, or other object with a' 

470 ' get_help_text() method, not %r' % (self.formatter,)) 

471 return 

472 

473 def default_help_func(self, cmd_, subcmds_, args_, command_): 

474 """The default help handler function. Called when either the help flag 

475 or subcommand is passed. 

476 

477 Prints the output of the help formatter instance attached to 

478 this HelpHandler and exits with exit code 0. 

479 

480 """ 

481 echo(self.formatter.get_help_text(command_, subcmds=subcmds_, program_name=cmd_)) 

482 

483 

484"""Usage: cmd_name sub_cmd [..as many subcommands as the max] --flags args ... 

485 

486Possible commands: 

487 

488(One of the possible styles below) 

489 

490Flags: 

491 Group name (if grouped): 

492 -F, --flag VALUE Help text goes here. (integer, defaults to 3) 

493 

494Flag help notes: 

495 

496* don't display parenthetical if it's string/None 

497* Also need to indicate required and mutual exclusion ("not with") 

498* Maybe experimental / deprecated support 

499* General flag listing should also include flags up the chain 

500 

501Subcommand listing styles: 

502 

503* Grouped, one-deep, flag overview on each 

504* One-deep, grouped or alphabetical, help string next to each 

505* Grouped by tree (new group whenever a subtree of more than one 

506 member finishes), with help next to each. 

507 

508What about extra lines in the help (like zfs) (maybe each individual 

509line can be a template string?) 

510 

511TODO: does face need built-in support for version subcommand/flag, 

512basically identical to help? 

513 

514Group names can be ints or strings. When, group names are strings, 

515flags are indented under a heading consisting of the string followed 

516by a colon. All ungrouped flags go under a 'General Flags' group 

517name. When group names are ints, groups are not indented, but a 

518newline is still emitted by each group. 

519 

520Alphabetize should be an option, otherwise everything stays in 

521insertion order. 

522 

523Subcommands without handlers should not be displayed in help. Also, 

524their implicit handler prints the help. 

525 

526Subcommand groups could be Commands with name='', and they can only be 

527added to other commands, where they would embed as siblings instead of 

528as subcommands. Similar to how clastic subapplications can be mounted 

529without necessarily adding to the path. 

530 

531Is it better to delegate representations out or keep them all within 

532the help builder? 

533 

534--- 

535 

536Help needs: a flag (and a way to disable it), as well as a renderer. 

537 

538Usage: 

539 

540Doc 

541 

542Subcommands: 

543 

544... ... 

545 

546Flags: 

547 

548... 

549 

550Postdoc 

551 

552 

553{usage_label} {cmd_name} {subcmd_path} {subcmd_blank} {flags_blank} {posargs_label} 

554 

555{cmd.doc} 

556 

557{subcmd_heading} 

558 

559 {subcmd.name} {subcmd.doc} {subcmd.post_doc} 

560 

561{flags_heading} 

562 

563 {group_name}: 

564 

565 {flag_label} {flag.doc} {flag.post_doc} 

566 

567{cmd.post_doc} 

568 

569 

570-------- 

571 

572# Grouping 

573 

574Effectively sorted on: (group_name, group_index, sort_order, label) 

575 

576But group names should be based on insertion order, with the 

577default-grouped/ungrouped items showing up in the last group. 

578 

579# Wrapping / Alignment 

580 

581Docs start at the position after the longest "left-hand side" 

582(LHS/"key") item that would not cause the first line of the docs to be 

583narrower than the minimum doc width. 

584 

585LHSes which do extend beyond this point will be on their own line, 

586with the doc starting on the line below. 

587 

588# Window width considerations 

589 

590With better termios-based logic in place to get window size, there are 

591going to be a lot of wider-than-80-char help messages. 

592 

593The goal of help message alignment is to help eyes track across from a 

594flag or subcommand to its corresponding doc. Rather than maximizing 

595width usage or topping out at a max width limit, we should be 

596balancing or at least limiting the amount of whitespace between the 

597shortest flag and its doc. (TODO) 

598 

599A width limit might still make sense because reading all the way 

600across the screen can be tiresome, too. 

601 

602TODO: padding_top and padding_bottom attributes on various displays 

603(esp FlagDisplay) to enable finer grained whitespace control without 

604complicated group setups. 

605 

606"""