httpie-cli/extras/scripts/generate_man_pages.py
Batuhan Taskaya f9b5c2f696
Man page fixes (#1364)
- Highlighting for options (-x, --x) now doesn't strip the prefix (may be whitespace).
- Escape sequences are now cross-platform compatible (directly taken by groff/troff [man's renderer])
- Now we check for the section before displaying the man pages.
- On MacOS, there is HTTP(n) which is different from our HTTP(1). This used to conflict with it, and we showed the wrong page. Now we specifically ask foir HTTP(1).
- Errors that might happen (e.g non executable man command) is now suppressed. So in the worst case (if anything regarding man execution goes wrong), we'll always display the manual.
- Docs for man pages.
- HTTPie man pages.
- Epilog for the man pages (see also)
- Auto-generated comments.
2022-05-05 11:17:37 -07:00

184 lines
5.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import re
from contextlib import contextmanager
from pathlib import Path
from typing import Optional, Iterator, Iterable
import httpie
from httpie.cli.definition import options as core_options
from httpie.cli.options import ParserSpec
from httpie.manager.cli import options as manager_options
from httpie.output.ui.rich_help import OptionsHighlighter, to_usage
from httpie.output.ui.rich_utils import render_as_string
from httpie.utils import split
# Escape certain characters so they are rendered properly on
# all terminals.
# https://man7.org/linux/man-pages/man7/groff_char.7.html
ESCAPE_MAP = {
'"': '\[dq]',
"'": '\[aq]',
'~': '\(ti',
'': "\(ga",
'\\': '\e',
}
ESCAPE_MAP = {ord(key): value for key, value in ESCAPE_MAP.items()}
EXTRAS_DIR = Path(__file__).parent.parent
MAN_PAGE_PATH = EXTRAS_DIR / 'man'
PROJECT_ROOT = EXTRAS_DIR.parent
OPTION_HIGHLIGHT_RE = re.compile(
OptionsHighlighter.highlights[0]
)
class ManPageBuilder:
def __init__(self):
self.source = []
def title_line(
self,
full_name: str,
program_name: str,
program_version: str,
last_edit_date: str,
) -> None:
self.source.append(
f'.TH {program_name} 1 "{last_edit_date}" '
f'"{full_name} {program_version}" "{full_name} Manual"'
)
def set_name(self, program_name: str) -> None:
with self.section('NAME'):
self.write(program_name)
def write(self, text: str, *, bold: bool = False) -> None:
if bold:
text = '.B ' + text
self.source.append(text)
def separate(self) -> None:
self.source.append('.PP')
def format_desc(self, desc: str) -> str:
description = _escape_and_dedent(desc)
description = OPTION_HIGHLIGHT_RE.sub(
# Boldify the option part, but don't remove the prefix (start of the match).
lambda match: match[1] + self.boldify(match['option']),
description
)
return description
def add_comment(self, comment: str) -> None:
self.source.append(f'.\\" {comment}')
def add_options(self, options: Iterable[str], *, metavar: Optional[str] = None) -> None:
text = ", ".join(map(self.boldify, options))
if metavar:
text += f' {self.underline(metavar)}'
self.write(f'.IP "{text}"')
def build(self) -> str:
return '\n'.join(self.source)
@contextmanager
def section(self, section_name: str) -> Iterator[None]:
self.write(f'.SH {section_name}')
self.in_section = True
yield
self.in_section = False
def underline(self, text: str) -> str:
return r'\fI\,{}\/\fR'.format(text)
def boldify(self, text: str) -> str:
return r'\fB\,{}\/\fR'.format(text)
def _escape_and_dedent(text: str) -> str:
lines = []
for should_act, line in enumerate(text.splitlines()):
# Only dedent after the first line.
if should_act:
if line.startswith(' '):
line = line[4:]
lines.append(line)
return '\n'.join(lines).translate(ESCAPE_MAP)
def to_man_page(program_name: str, spec: ParserSpec, *, is_top_level_cmd: bool = False) -> str:
builder = ManPageBuilder()
builder.add_comment(
f"This file is auto-generated from the parser declaration "
+ (f"in {Path(spec.source_file).relative_to(PROJECT_ROOT)} " if spec.source_file else "")
+ f"by {Path(__file__).relative_to(PROJECT_ROOT)}."
)
builder.title_line(
full_name='HTTPie',
program_name=program_name,
program_version=httpie.__version__,
last_edit_date=httpie.__date__,
)
builder.set_name(program_name)
with builder.section('SYNOPSIS'):
# `http` and `https` are commands that can be directly used, so they can have
# have a valid usage. But `httpie` is a top-level command with multiple sub commands,
# so for the synopsis we'll only reference the `httpie` name.
if is_top_level_cmd:
synopsis = program_name
else:
synopsis = render_as_string(to_usage(spec, program_name=program_name))
builder.write(synopsis)
with builder.section('DESCRIPTION'):
builder.write(spec.description)
if spec.man_page_hint:
builder.write(spec.man_page_hint)
for index, group in enumerate(spec.groups, 1):
with builder.section(group.name):
if group.description:
builder.write(group.description)
for argument in group.arguments:
if argument.is_hidden:
continue
raw_arg = argument.serialize(isolation_mode=True)
metavar = raw_arg.get('metavar')
if raw_arg.get('is_positional'):
# In case of positional arguments, metavar is always equal
# to the list of options (e.g `METHOD`).
metavar = None
builder.add_options(raw_arg['options'], metavar=metavar)
desc = builder.format_desc(raw_arg.get('description', ''))
builder.write('\n' + desc + '\n')
builder.separate()
if spec.epilog:
with builder.section('SEE ALSO'):
builder.write(builder.format_desc(spec.epilog))
return builder.build()
def main() -> None:
for program_name, spec, config in [
('http', core_options, {}),
('https', core_options, {}),
('httpie', manager_options, {'is_top_level_cmd': True}),
]:
with open((MAN_PAGE_PATH / program_name).with_suffix('.1'), 'w') as stream:
stream.write(to_man_page(program_name, spec, **config))
if __name__ == '__main__':
main()