Skip to content

Commit 0641f39

Browse files
authored
Make help pages prettier. (#215)
1 parent fb432e5 commit 0641f39

12 files changed

+193
-21
lines changed

docs/source/changes.rst

+9-2
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,21 @@ all releases are available on `PyPI <https://pypi.org/project/pytask>`_ and
77
`Anaconda.org <https://anaconda.org/conda-forge/pytask>`_.
88

99

10+
0.1.8 - 2022-02-07
11+
------------------
12+
13+
- :gh:`210` allows ``__tracebackhide__`` to be a callable which accepts the current
14+
exception as an input. Closes :gh:`145`.
15+
- :gh:`213` improves coverage and reporting.
16+
- :gh:`215` makes the help pages of the CLI prettier.
17+
18+
1019
0.1.7 - 2022-01-28
1120
------------------
1221

1322
- :gh:`153` adds support for Python 3.10 which requires pony >= 0.7.15.
1423
- :gh:`192` deprecates Python 3.6.
1524
- :gh:`209` cancels previous CI jobs when a new job is started.
16-
- :gh:`210` allows ``__tracebackhide__`` to be a callable which accepts the current
17-
exception as an input. Closes :gh:`145`.
1825

1926

2027
0.1.6 - 2022-01-27

src/_pytask/build.py

+5-4
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import TYPE_CHECKING
77

88
import click
9+
from _pytask.click import ColoredCommand
910
from _pytask.config import hookimpl
1011
from _pytask.console import console
1112
from _pytask.exceptions import CollectionError
@@ -92,7 +93,7 @@ def main(config_from_cli: dict[str, Any]) -> Session:
9293
return session
9394

9495

95-
@click.command()
96+
@click.command(cls=ColoredCommand)
9697
@click.option(
9798
"--debug-pytask",
9899
is_flag=True,
@@ -119,10 +120,10 @@ def main(config_from_cli: dict[str, Any]) -> Session:
119120
help="Choose whether tracebacks should be displayed or not. [default: yes]",
120121
)
121122
def build(**config_from_cli: Any) -> NoReturn:
122-
"""Collect and execute tasks and report the results.
123+
"""Collect tasks, execute them and report the results.
123124
124-
This is the default command of pytask which searches given paths or the current
125-
working directory for tasks to execute them. A report informs you on the results.
125+
This is pytask's default command. pytask collects tasks from the given paths or the
126+
current working directory, executes them and reports the results.
126127
127128
"""
128129
config_from_cli["command"] = "build"

src/_pytask/clean.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import attr
1515
import click
16+
from _pytask.click import ColoredCommand
1617
from _pytask.config import hookimpl
1718
from _pytask.config import IGNORED_TEMPORARY_FILES_AND_FOLDERS
1819
from _pytask.console import console
@@ -69,7 +70,7 @@ def pytask_post_parse(config: dict[str, Any]) -> None:
6970
]
7071

7172

72-
@click.command()
73+
@click.command(cls=ColoredCommand)
7374
@click.option(
7475
"--mode",
7576
type=click.Choice(["dry-run", "interactive", "force"]),
@@ -80,7 +81,7 @@ def pytask_post_parse(config: dict[str, Any]) -> None:
8081
"-q", "--quiet", is_flag=True, help="Do not print the names of the removed paths."
8182
)
8283
def clean(**config_from_cli: Any) -> NoReturn:
83-
"""Clean provided paths by removing files unknown to pytask."""
84+
"""Clean the provided paths by removing files unknown to pytask."""
8485
config_from_cli["command"] = "clean"
8586

8687
try:

src/_pytask/cli.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@
66

77
import click
88
import pluggy
9+
from _pytask.click import ColoredGroup
910
from _pytask.config import hookimpl
1011
from _pytask.pluginmanager import get_plugin_manager
11-
from click_default_group import DefaultGroup
1212
from packaging.version import parse as parse_version
1313

1414

15-
_CONTEXT_SETTINGS: dict[str, Any] = {"help_option_names": ["-h", "--help"]}
15+
_CONTEXT_SETTINGS: dict[str, Any] = {"help_option_names": ("-h", "--help")}
1616

1717

1818
if parse_version(click.__version__) < parse_version("8"):
@@ -92,14 +92,14 @@ def pytask_add_hooks(pm: pluggy.PluginManager) -> None:
9292

9393

9494
@click.group(
95-
cls=DefaultGroup,
95+
cls=ColoredGroup,
9696
context_settings=_CONTEXT_SETTINGS,
9797
default="build",
9898
default_if_no_args=True,
9999
)
100100
@click.version_option(**_VERSION_OPTION_KWARGS)
101101
def cli() -> None:
102-
"""The command line interface of pytask."""
102+
"""Manage your tasks with pytask."""
103103
pass
104104

105105

src/_pytask/click.py

+132
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
import click
6+
from _pytask import __version__ as version
7+
from _pytask.console import console
8+
from click_default_group import DefaultGroup
9+
from rich.highlighter import RegexHighlighter
10+
from rich.panel import Panel
11+
from rich.table import Table
12+
from rich.text import Text
13+
14+
15+
_SWITCH_REGEX = r"(?P<switch>\-\w)\b"
16+
_OPTION_REGEX = r"(?P<option>\-\-[\w\-]+)"
17+
_METAVAR_REGEX = r"\-\-[\w\-]+(?P<metavar>[ |=][\w\.:]+)"
18+
19+
20+
class OptionHighlighter(RegexHighlighter):
21+
highlights = [_SWITCH_REGEX, _OPTION_REGEX, _METAVAR_REGEX]
22+
23+
24+
class ColoredGroup(DefaultGroup):
25+
def format_help(self: DefaultGroup, ctx: Any, formatter: Any) -> None: # noqa: U100
26+
highlighter = OptionHighlighter()
27+
28+
console.print(
29+
f"[b]pytask[/b] [dim]v{version}[/]\n", justify="center", highlight=False
30+
)
31+
32+
console.print(
33+
"Usage: [b]pytask[/b] [b][OPTIONS][/b] [b][COMMAND][/b] [b][PATHS][/b]\n"
34+
)
35+
36+
console.print(self.help, style="dim")
37+
console.print()
38+
39+
commands_table = Table(highlight=True, box=None, show_header=False)
40+
41+
for command_name in sorted(self.commands):
42+
command = self.commands[command_name]
43+
44+
if command_name == self.default_cmd_name:
45+
formatted_name = Text(command_name + " *", style="command")
46+
else:
47+
formatted_name = Text(command_name, style="command")
48+
49+
commands_table.add_row(formatted_name, highlighter(command.help))
50+
51+
console.print(
52+
Panel(
53+
commands_table,
54+
title="[bold #ffffff]Commands[/bold #ffffff]",
55+
title_align="left",
56+
border_style="grey37",
57+
)
58+
)
59+
60+
print_options(self, ctx)
61+
62+
console.print(
63+
"[bold red]♥[/bold red] [white]https://pytask-dev.readthedocs.io[/]",
64+
justify="right",
65+
)
66+
67+
68+
class ColoredCommand(click.Command):
69+
"""Override Clicks help with a Richer version."""
70+
71+
def format_help(
72+
self: click.Command, ctx: Any, formatter: Any # noqa: U100
73+
) -> None:
74+
console.print(
75+
f"[b]pytask[/b] [dim]v{version}[/]\n", justify="center", highlight=False
76+
)
77+
78+
console.print(
79+
f"Usage: [b]pytask[/b] [b]{self.name}[/b] [b][OPTIONS][/b] [b][PATHS][/b]\n"
80+
)
81+
82+
console.print(self.help, style="dim")
83+
console.print()
84+
85+
print_options(self, ctx)
86+
87+
console.print(
88+
"[bold red]♥[/bold red] [white]https://pytask-dev.readthedocs.io[/]",
89+
justify="right",
90+
)
91+
92+
93+
def print_options(group_or_command: click.Command | DefaultGroup, ctx: Any) -> None:
94+
highlighter = OptionHighlighter()
95+
96+
options_table = Table(highlight=True, box=None, show_header=False)
97+
98+
for param in group_or_command.get_params(ctx):
99+
100+
if isinstance(param, click.Argument):
101+
continue
102+
103+
# The ordering of -h and --help is not fixed.
104+
if param.name == "help":
105+
opt1 = highlighter("-h")
106+
opt2 = highlighter("--help")
107+
elif len(param.opts) == 2:
108+
opt1 = highlighter(param.opts[0])
109+
opt2 = highlighter(param.opts[1])
110+
else:
111+
opt1 = Text("")
112+
opt2 = highlighter(param.opts[0])
113+
114+
if param.metavar:
115+
opt2 += Text(f" {param.metavar}", style="metavar")
116+
117+
help_record = param.get_help_record(ctx)
118+
if help_record is None:
119+
help_text = ""
120+
else:
121+
help_text = Text.from_markup(param.get_help_record(ctx)[-1], emoji=False)
122+
123+
options_table.add_row(opt1, opt2, highlighter(help_text))
124+
125+
console.print(
126+
Panel(
127+
options_table,
128+
title="[bold #ffffff]Options[/bold #ffffff]",
129+
title_align="left",
130+
border_style="grey37",
131+
)
132+
)

src/_pytask/collect_command.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import TYPE_CHECKING
88

99
import click
10+
from _pytask.click import ColoredCommand
1011
from _pytask.config import hookimpl
1112
from _pytask.console import console
1213
from _pytask.console import create_url_style_for_path
@@ -47,10 +48,10 @@ def pytask_parse_config(
4748
config["nodes"] = config_from_cli.get("nodes", False)
4849

4950

50-
@click.command()
51+
@click.command(cls=ColoredCommand)
5152
@click.option("--nodes", is_flag=True, help="Show a task's dependencies and products.")
5253
def collect(**config_from_cli: Any | None) -> NoReturn:
53-
"""Collect tasks from paths."""
54+
"""Collect tasks and report information about them."""
5455
config_from_cli["command"] = "collect"
5556

5657
try:

src/_pytask/console.py

+6
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070

7171
theme = Theme(
7272
{
73+
# Statuses
7374
"failed": "#BF2D2D",
7475
"failed.textonly": "#ffffff on #BF2D2D",
7576
"neutral": "",
@@ -78,6 +79,11 @@
7879
"success": "#137C39",
7980
"success.textonly": "#ffffff on #137C39",
8081
"warning": "#F4C041",
82+
# Help page.
83+
"command": "bold #137C39",
84+
"option": "bold #F4C041",
85+
"switch": "bold #D54523",
86+
"metavar": "bold yellow",
8187
}
8288
)
8389

src/_pytask/graph.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import click
1010
import networkx as nx
11+
from _pytask.click import ColoredCommand
1112
from _pytask.compat import check_for_optional_program
1213
from _pytask.compat import import_optional_dependency
1314
from _pytask.config import hookimpl
@@ -104,7 +105,7 @@ def _rank_direction_callback(
104105
)
105106

106107

107-
@click.command()
108+
@click.command(cls=ColoredCommand)
108109
@click.option("-l", "--layout", type=str, default=None, help=_HELP_TEXT_LAYOUT)
109110
@click.option("-o", "--output-path", type=str, default=None, help=_HELP_TEXT_OUTPUT)
110111
@click.option(
@@ -113,7 +114,7 @@ def _rank_direction_callback(
113114
help=_HELP_TEXT_RANK_DIRECTION,
114115
)
115116
def dag(**config_from_cli: Any) -> NoReturn:
116-
"""Create a visualization of the project's DAG."""
117+
"""Create a visualization of the project's directed acyclic graph."""
117118
try:
118119
pm = get_plugin_manager()
119120
from _pytask import cli

src/_pytask/mark/__init__.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import attr
1010
import click
1111
import networkx as nx
12+
from _pytask.click import ColoredCommand
1213
from _pytask.config import hookimpl
1314
from _pytask.console import console
1415
from _pytask.dag import task_and_preceding_tasks
@@ -42,7 +43,7 @@
4243
]
4344

4445

45-
@click.command()
46+
@click.command(cls=ColoredCommand)
4647
def markers(**config_from_cli: Any) -> NoReturn:
4748
"""Show all registered markers."""
4849
config_from_cli["command"] = "markers"

src/_pytask/parameters.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
type=str,
1717
multiple=True,
1818
help=(
19-
"A pattern to ignore files or directories. For example, ``task_example.py`` or "
20-
"``src/*``."
19+
"A pattern to ignore files or directories. For example, task_example.py or "
20+
"src/*."
2121
),
2222
callback=falsy_to_none_callback,
2323
)

src/_pytask/profile.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from typing import TYPE_CHECKING
1313

1414
import click
15+
from _pytask.click import ColoredCommand
1516
from _pytask.config import hookimpl
1617
from _pytask.console import console
1718
from _pytask.console import format_task_id
@@ -97,15 +98,15 @@ def _create_or_update_runtime(task_name: str, start: float, end: float) -> None:
9798
setattr(runtime, attr, val)
9899

99100

100-
@click.command()
101+
@click.command(cls=ColoredCommand)
101102
@click.option(
102103
"--export",
103104
type=str,
104105
default=None,
105106
help="Export the profile in the specified format.",
106107
)
107108
def profile(**config_from_cli: Any) -> NoReturn:
108-
"""Show profile information on collected tasks."""
109+
"""Show information about tasks like runtime and memory consumption of products."""
109110
config_from_cli["command"] = "profile"
110111

111112
try:

tests/test_cli.py

+21
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,31 @@
33
import subprocess
44

55
import pytest
6+
from _pytask.outcomes import ExitCode
67
from pytask import __version__
8+
from pytask import cli
79

810

911
@pytest.mark.end_to_end
1012
def test_version_option():
1113
process = subprocess.run(["pytask", "--version"], capture_output=True)
1214
assert "pytask, version " + __version__ in process.stdout.decode("utf-8")
15+
16+
17+
@pytest.mark.end_to_end
18+
@pytest.mark.parametrize("help_option", ["-h", "--help"])
19+
@pytest.mark.parametrize(
20+
"commands",
21+
[
22+
("pytask",),
23+
("pytask", "build"),
24+
("pytask", "clean"),
25+
("pytask", "collect"),
26+
("pytask", "dag"),
27+
("pytask", "markers"),
28+
("pytask", "profile"),
29+
],
30+
)
31+
def test_help_pages(runner, commands, help_option):
32+
result = runner.invoke(cli, [*commands, help_option])
33+
assert result.exit_code == ExitCode.OK

0 commit comments

Comments
 (0)