# Copyright 2021-2022 Canonical Ltd.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License version 3 as published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
"""Provide all help texts."""
from __future__ import annotations
import argparse
import enum
import textwrap
from operator import attrgetter
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from craft_cli.dispatcher import BaseCommand, CommandGroup
# if the `help` of any argument (global or for any command, option or parameter) is set to this
# value, the argument will not be shown in help messages; the default is to support the
# non-documented argparse attribute (so if users were using it, will just work) in a secure way
# in case it disappears in the future.
HIDDEN = argparse.SUPPRESS
# max columns used in the terminal
TERMINAL_WIDTH = 72
# generic intro and outro texts
HEADER = """
Usage:
{appname} [help] <command>
"""
USAGE = """\
Usage: {appname} [options] command [args]...
Try '{full_command} -h' for help.
Error: {error_message}
"""
# the used formats, defaults to first one
OutputFormat = enum.Enum("OutputFormat", "plain markdown")
def _build_item_plain(title: str, text: str, title_space: int) -> list[str]:
"""Prepare an item for the help in plain format, generically a title and a text aligned.
This is how the plain mode is built:
- the title starts in column 4 with an extra ':', aligned to the right
- the text starts in 4 plus the title space; if too wide it's wrapped.
"""
# wrap the general text to the desired max width (discounting the space for the title,
# the first 4 spaces, the two spaces to separate title/text, and the ':'
not_title_space = 7
text_space = TERMINAL_WIDTH - title_space - not_title_space
wrapped_lines = textwrap.wrap(text, text_space)
# first line goes with the title at column 4
first = f" {title:>{title_space}s}: {wrapped_lines[0]}"
result = [first]
# the rest (if any) still aligned but without title
for line in wrapped_lines[1:]:
result.append(" " * (title_space + not_title_space) + line)
return result
[docs]
def process_overview_for_markdown(text: str) -> str:
"""Process a regular overview to be rendered with markdown.
In detail:
- Join all lines for the same paragraph (as wrapping is responsibility of the renderer)
- Dedent and wrap with triple-backtick all indented blocks
Paragraphs are separated by empty lines
"""
lines = [x.rstrip() for x in text.strip().split("\n")]
# group all the lines in different blocks, each holding what would be a
# paragraph (detected by the empty line that separates them)
blocks: list[list[str]] = [[]]
for line in lines:
if line:
blocks[-1].append(line)
else:
blocks.append([])
# convert each of the block/paragraph into their markdown representation
result: list[str] = []
for block in blocks:
if block and block[0] and block[0][0] == " ":
# it is indented! dedent and wrap with backticks
dedented = textwrap.dedent("\n".join(block))
text = f"```text\n{dedented}\n```"
else:
# regular text
text = " ".join(block)
# include the processed text and an empty line; this empty line will be a separation
# between paragraphs or the final newline at the end of the whole text
result.extend((text, ""))
return "\n".join(result)
[docs]
class HelpBuilder:
"""Produce the different help texts."""
def __init__(
self,
appname: str,
general_summary: str,
command_groups: list[CommandGroup],
docs_base_url: str | None = None,
) -> None:
"""Initialize the help builder.
:param appname: The name of the application.
:param general_summary: A summary of the application.
:param command_groups: The CommandGroups for the application.
:param docs_base_url: The base URL for the documentation.
"""
self.appname = appname
self.general_summary = general_summary
self.command_groups = command_groups
self._docs_base_url = docs_base_url
if docs_base_url and docs_base_url.endswith("/"):
self._docs_base_url = docs_base_url[:-1]
[docs]
def get_usage_message(self, error_message: str, command: str = "") -> str:
"""Build a usage and error message.
The command is the extra string used after the application name to build the
full command that will be shown in the usage message; for example, having an
application name of "someapp":
- if command is "" it will be shown "Try 'appname -h' for help".
- if command is "version" it will be shown "Try 'appname version -h' for help"
The error message is the specific problem in the given parameters.
"""
full_command = f"{self.appname} {command}" if command else self.appname
return USAGE.format(
appname=self.appname, full_command=full_command, error_message=error_message
)
[docs]
def get_full_help(self, global_options: list[tuple[str, str]]) -> str:
"""Produce the text for the default help.
- global_options: options defined at application level (not in the commands),
with the (options, description) structure
The help text has the following structure:
- usage
- summary
- common commands listed and described shortly
- all commands grouped, just listed
- more help and documentation
"""
textblocks = []
# title
textblocks.append(HEADER.format(appname=self.appname))
# summary
textblocks.append("Summary:" + textwrap.indent(self.general_summary, " "))
# column alignment is dictated by longest common commands names and groups names
max_title_len = 0
# collect common commands
common_commands = []
for command_group in self.command_groups:
max_title_len = max(len(command_group.name), max_title_len)
for cmd in command_group.commands:
if cmd.common:
common_commands.append(cmd)
max_title_len = max(len(cmd.name), max_title_len)
for title, _ in global_options:
max_title_len = max(len(title), max_title_len)
global_lines = ["Global options:"]
for title, text in global_options:
if text is not HIDDEN:
global_lines.extend(_build_item_plain(title, text, max_title_len))
textblocks.append("\n".join(global_lines))
common_lines = ["Starter commands:"]
for cmd in sorted(common_commands, key=attrgetter("name")):
common_lines.extend(_build_item_plain(cmd.name, cmd.help_msg, max_title_len))
textblocks.append("\n".join(common_lines))
grouped_lines = ["Commands can be classified as follows:"]
for command_group in sorted(self.command_groups, key=attrgetter("name")):
command_names = [cmd.name for cmd in command_group.commands if not cmd.hidden]
if not command_group.ordered:
command_names.sort()
command_names_str = ", ".join(command_names)
grouped_lines.extend(
_build_item_plain(command_group.name, command_names_str, max_title_len)
)
textblocks.append("\n".join(grouped_lines))
more_help_text = textwrap.dedent(
f"""
For more information about a command, run '{self.appname} help <command>'.
For a summary of all commands, run '{self.appname} help --all'."""
)
# append documentation links to block for more help
if self._docs_base_url:
more_help_text += (
f"\nFor more information about {self.appname}, "
f"check out: {self._docs_base_url}"
)
textblocks.append(more_help_text)
# join all stripped blocks, leaving ONE empty blank line between
return "\n\n".join(block.strip() for block in textblocks) + "\n"
[docs]
def get_detailed_help(self, global_options: list[tuple[str, str]]) -> str:
"""Produce the text for the detailed help.
- global_options: options defined at application level (not in the commands),
with the (options, description) structure
The help text has the following structure:
- usage
- summary
- global options
- all commands shown with description, grouped
- more help and documentation
"""
textblocks = []
# title
textblocks.append(HEADER.format(appname=self.appname))
# summary
textblocks.append("Summary:" + textwrap.indent(self.general_summary, " "))
# column alignment is dictated by longest common commands names and groups names
max_title_len = 0
for command_group in self.command_groups:
for cmd in command_group.commands:
max_title_len = max(len(cmd.name), max_title_len)
for title, _ in global_options:
max_title_len = max(len(title), max_title_len)
global_lines = ["Global options:"]
for title, text in global_options:
if text is not HIDDEN:
global_lines.extend(_build_item_plain(title, text, max_title_len))
textblocks.append("\n".join(global_lines))
textblocks.append("Commands can be classified as follows:")
for command_group in self.command_groups:
group_lines = [f"{command_group.name}:"]
for cmd in command_group.commands:
if cmd.hidden:
continue
group_lines.extend(_build_item_plain(cmd.name, cmd.help_msg, max_title_len))
textblocks.append("\n".join(group_lines))
more_help_text = (
f"For more information about a specific command, run '{self.appname} "
"help <command>'."
""
)
if self._docs_base_url:
more_help_text += (
f"\nFor more information about {self.appname}, "
f"check out: {self._docs_base_url}"
)
textblocks.append(more_help_text)
# join all stripped blocks, leaving ONE empty blank line between
return "\n\n".join(block.strip() for block in textblocks) + "\n"
def _build_plain_command_help(
self,
command: BaseCommand,
usage: str,
parameters: list[tuple[str, str]],
options: list[tuple[str, str]],
other_command_names: list[str],
) -> list[str]:
"""Build the command help in its plain version.
The help text has the following structure:
- usage
- summary
- positional arguments (only if parameters are not empty)
- options
- other related commands
- help for all commands and documentation
"""
textblocks = []
textblocks.append(
textwrap.dedent(
f"""\
Usage:
{usage}
"""
)
)
overview = textwrap.indent(command.overview, " ")
textblocks.append(f"Summary:{overview}")
# column alignment is dictated by longest options title
max_title_len = max(len(title) for title, text in options)
if parameters:
# command positional arguments
positional_args_lines = ["Positional arguments:"]
for title, text in parameters:
positional_args_lines.extend(_build_item_plain(title, text, max_title_len))
textblocks.append("\n".join(positional_args_lines))
# command options
option_lines = ["Options:"]
for title, text in options:
option_lines.extend(_build_item_plain(title, text, max_title_len))
textblocks.append("\n".join(option_lines))
if other_command_names:
see_also_block = ["See also:"]
see_also_block.extend((" " + name) for name in sorted(other_command_names))
textblocks.append("\n".join(see_also_block))
# help for all commands
more_help_text = f"For a summary of all commands, run '{self.appname} help --all'."
if self._docs_base_url:
command_url = f"{self._docs_base_url}/reference/commands/{command.name}"
more_help_text += f"\nFor more information, check out: {command_url}"
textblocks.append(more_help_text)
return textblocks
def _build_markdown_command_help(
self,
command: BaseCommand,
usage: str,
parameters: list[tuple[str, str]],
options: list[tuple[str, str]],
other_command_names: list[str],
) -> list[str]:
"""Build the command help in its markdown version.
The help text has the following structure:
- usage
- summary
- positional arguments (only if parameters are not empty)
- options
- other related commands
"""
textblocks = []
textblocks.append(
textwrap.dedent(
f"""\
## Usage:
```text
{usage}
```
"""
)
)
overview = process_overview_for_markdown(command.overview)
textblocks.append(f"## Summary:\n\n{overview}")
if parameters:
parameters_lines = [
"## Positional arguments:",
"| | |",
"|-|-|",
]
for title, text in parameters:
parameters_lines.append(f"| `{title}` | {text} |")
textblocks.append("\n".join(parameters_lines))
option_lines = [
"## Options:",
"| | |",
"|-|-|",
]
for title, text in options:
option_lines.append(f"| `{title}` | {text} |")
textblocks.append("\n".join(option_lines))
if other_command_names:
see_also_block = ["## See also:"]
see_also_block.extend(f"- `{name}`" for name in sorted(other_command_names))
textblocks.append("\n".join(see_also_block))
return textblocks
[docs]
def get_command_help(
self,
command: BaseCommand,
arguments: list[tuple[str, str]],
output_format: OutputFormat,
) -> str:
"""Produce the text for each command's help in any output format.
- command: the instantiated command for which help is prepared
- arguments: all command options and parameters, with the (name, description) structure;
note that any argument with description being `HIDDEN` will be ignored
- output_format: the selected output format
The help text structure depends of the output format.
"""
# separate all arguments into the parameters and optional ones, just checking
# if first char is a dash
parameters = []
options = []
for name, title in arguments:
if title is HIDDEN:
continue
if name[0] == "-":
options.append((name, title))
else:
parameters.append((name, title))
usage = f"{self.appname} {command.name} [options]"
if parameters:
usage += " " + " ".join(f"<{parameter[0]}>" for parameter in parameters)
for command_group in self.command_groups:
if any(isinstance(command, command_class) for command_class in command_group.commands):
break
else:
raise RuntimeError("Internal inconsistency in commands groups")
other_command_names = [
c.name for c in command_group.commands if not isinstance(command, c)
]
if output_format == OutputFormat.markdown:
builder = self._build_markdown_command_help
else:
builder = self._build_plain_command_help
textblocks = builder(command, usage, parameters, options, other_command_names)
# join all stripped blocks, leaving ONE empty blank line between
return "\n\n".join(block.strip() for block in textblocks) + "\n"