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

174 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 

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. 

153 

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

155 stouter (conservative with vertical space) than other conventional 

156 help messages. 

157 

158 The default output looks like:: 

159 

160 Usage: example.py subcommand [FLAGS] 

161 

162 Does a bit of busy work 

163 

164 

165 Subcommands: 

166 

167 sum Just a lil fun in the sum 

168 subtract 

169 print 

170 

171 

172 Flags: 

173 

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

175 --verbose / -V 

176 

177 

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

179 keyword arguments, the most important of which are highlighted 

180 here. 

181 

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. 

195 

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. 

199 

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. 

204 

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. 

208 

209 """ 

210 default_context = dict(DEFAULT_CONTEXT) 

211 

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}') 

218 

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

227 

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. 

232 

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``.) 

243 

244 """ 

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

246 ctx = self.ctx 

247 

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

249 append = ret.append 

250 append(ctx['group_break']) 

251 

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

253 if subcmds: 

254 parser = parser.subprs_map[subcmds] 

255 

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

262 

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) 

266 

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) 

279 

280 append(ctx['section_break']) 

281 

282 if not shown_flags: 

283 return '\n'.join(ret) 

284 

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) 

288 

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) 

302 

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

309 

310 ret.extend(flag_lines) 

311 

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

313 

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: 

318 

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

320 

321 """ 

322 ctx = self.ctx 

323 subcmds = tuple(subcmds or ()) 

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

325 append = parts.append 

326 

327 program_name = program_name or parser.name 

328 

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

330 

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

336 

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

338 if subcmds: 

339 parser = parser.subprs_map[subcmds] 

340 

341 flags = parser.get_flags(with_hidden=False) 

342 

343 if flags: 

344 append('[FLAGS]') 

345 

346 if not parser.posargs.display.hidden: 

347 fmt_posargs_label = ctx['format_posargs_label'] 

348 append(fmt_posargs_label(parser.posargs)) 

349 

350 return ' '.join(parts) 

351 

352 

353 

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''' 

362 

363 

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. 

369 

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. 

373 

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()`. 

387 

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

396 

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}') 

405 

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 

420 

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. 

424 

425 Prints the output of the help formatter instance attached to 

426 this HelpHandler and exits with exit code 0. 

427 

428 """ 

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

430 

431 

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

433 

434Possible commands: 

435 

436(One of the possible styles below) 

437 

438Flags: 

439 Group name (if grouped): 

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

441 

442Flag help notes: 

443 

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 

448 

449Subcommand listing styles: 

450 

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. 

455 

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

457line can be a template string?) 

458 

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

460basically identical to help? 

461 

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. 

467 

468Alphabetize should be an option, otherwise everything stays in 

469insertion order. 

470 

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

472their implicit handler prints the help. 

473 

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. 

478 

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

480the help builder? 

481 

482--- 

483 

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

485 

486Usage: 

487 

488Doc 

489 

490Subcommands: 

491 

492... ... 

493 

494Flags: 

495 

496... 

497 

498Postdoc 

499 

500 

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

502 

503{cmd.doc} 

504 

505{subcmd_heading} 

506 

507 {subcmd.name} {subcmd.doc} {subcmd.post_doc} 

508 

509{flags_heading} 

510 

511 {group_name}: 

512 

513 {flag_label} {flag.doc} {flag.post_doc} 

514 

515{cmd.post_doc} 

516 

517 

518-------- 

519 

520# Grouping 

521 

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

523 

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

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

526 

527# Wrapping / Alignment 

528 

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. 

532 

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

534with the doc starting on the line below. 

535 

536# Window width considerations 

537 

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. 

540 

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) 

546 

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

548across the screen can be tiresome, too. 

549 

550TODO: padding_top and padding_bottom attributes on various displays 

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

552complicated group setups. 

553 

554"""