Skip to content

Commit 6125350

Browse files
authored
Add monkey patching of typer and click-repl (#98)
* Add monkeypatching of typer and click-repl * Add help text changelog entry
1 parent cf17a58 commit 6125350

File tree

6 files changed

+370
-2
lines changed

6 files changed

+370
-2
lines changed

CHANGELOG.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,18 @@ The **first number** is the major version (API changes, breaking changes)
88
The **second number** is the minor version (new features)
99
The **third number** is the patch version (bug fixes)
1010

11-
<!-- ## [Unreleased]() - 2024-mm-dd -->
12-
1311
<!-- changelog follows -->
1412

13+
## Unreleased
14+
15+
### Changed
16+
17+
- Styling of multiline help text in commands.
18+
19+
### Fixed
20+
21+
- REPL closing when certain errors are raised.
22+
1523
## [0.2.2](https://github.com/unioslo/harbor-cli/tree/harbor-cli-v0.2.2) - 2024-03-01
1624

1725
### Fixed

harbor_cli/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
#
33
# SPDX-License-Identifier: MIT
44
from __future__ import annotations
5+
from ._patches import patch_all
6+
7+
patch_all()
58

69
from . import logs # type: ignore # noreorder # configure logger first as side-effect
710
from . import *

harbor_cli/_patches/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from __future__ import annotations
2+
3+
from harbor_cli._patches import click_repl
4+
from harbor_cli._patches import typer
5+
6+
7+
def patch_all() -> None:
8+
"""Apply all patches to all modules."""
9+
typer.patch()
10+
click_repl.patch()

harbor_cli/_patches/click_repl.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
"""Patches for the click_repl package."""
2+
3+
from __future__ import annotations
4+
5+
import shlex
6+
import sys
7+
from typing import Any
8+
from typing import Dict
9+
from typing import Optional
10+
11+
import click
12+
import click_repl # type: ignore
13+
from click.exceptions import Exit as ClickExit
14+
from click_repl import ExitReplException # type: ignore
15+
from click_repl import bootstrap_prompt # type: ignore
16+
from click_repl import dispatch_repl_commands # type: ignore
17+
from click_repl import handle_internal_commands # type: ignore
18+
from prompt_toolkit.shortcuts import prompt
19+
20+
from harbor_cli._patches.common import get_patcher
21+
from harbor_cli.exceptions import handle_exception
22+
23+
patcher = get_patcher(f"click_repl version: {click_repl.__version__}")
24+
25+
26+
def patch_exception_handling() -> None:
27+
"""Patch click_repl's exception handling to fall back on the
28+
CLI's exception handlers instead of propagating them.
29+
30+
Without this patch, any exceptions other than SystemExit and ClickExit
31+
will cause the REPL to exit. This is not desirable, as we want
32+
raise exceptions for control flow purposes in commands to abort them,
33+
but not terminate the CLi completely.
34+
35+
A failed command should return to the REPL prompt instead of exiting
36+
the REPL.
37+
"""
38+
39+
def repl( # noqa: C901
40+
old_ctx: click.Context,
41+
prompt_kwargs: Optional[Dict[str, Any]] = None,
42+
allow_system_commands: bool = True,
43+
allow_internal_commands: bool = True,
44+
) -> Any:
45+
"""
46+
Start an interactive shell. All subcommands are available in it.
47+
48+
:param old_ctx: The current Click context.
49+
:param prompt_kwargs: Parameters passed to
50+
:py:func:`prompt_toolkit.shortcuts.prompt`.
51+
52+
If stdin is not a TTY, no prompt will be printed, but only commands read
53+
from stdin.
54+
55+
"""
56+
# parent should be available, but we're not going to bother if not
57+
group_ctx = old_ctx.parent or old_ctx
58+
group = group_ctx.command
59+
isatty = sys.stdin.isatty()
60+
61+
# Delete the REPL command from those available, as we don't want to allow
62+
# nesting REPLs (note: pass `None` to `pop` as we don't want to error if
63+
# REPL command already not present for some reason).
64+
repl_command_name = old_ctx.command.name
65+
if isinstance(group_ctx.command, click.CommandCollection):
66+
available_commands = { # type: ignore
67+
cmd_name: cmd_obj
68+
for source in group_ctx.command.sources
69+
for cmd_name, cmd_obj in source.commands.items() # type: ignore
70+
}
71+
else:
72+
available_commands = group_ctx.command.commands # type: ignore
73+
available_commands.pop(repl_command_name, None) # type: ignore
74+
75+
prompt_kwargs = bootstrap_prompt(prompt_kwargs, group)
76+
77+
if isatty:
78+
79+
def get_command():
80+
return prompt(**prompt_kwargs)
81+
82+
else:
83+
get_command = sys.stdin.readline
84+
85+
while True:
86+
try:
87+
command = get_command()
88+
except KeyboardInterrupt:
89+
continue
90+
except EOFError:
91+
break
92+
93+
if not command:
94+
if isatty:
95+
continue
96+
else:
97+
break
98+
99+
if allow_system_commands and dispatch_repl_commands(command):
100+
continue
101+
102+
if allow_internal_commands:
103+
try:
104+
result = handle_internal_commands(command) # type: ignore
105+
if isinstance(result, str):
106+
click.echo(result)
107+
continue
108+
except ExitReplException:
109+
break
110+
111+
try:
112+
args = shlex.split(command)
113+
except ValueError as e:
114+
click.echo(f"{type(e).__name__}: {e}")
115+
continue
116+
117+
try:
118+
with group.make_context(None, args, parent=group_ctx) as ctx:
119+
group.invoke(ctx)
120+
ctx.exit()
121+
except click.ClickException as e:
122+
e.show()
123+
except ClickExit:
124+
pass
125+
except SystemExit:
126+
pass
127+
except ExitReplException:
128+
break
129+
# PATCH: Patched to handle zabbix-cli exceptions
130+
except Exception as e:
131+
try:
132+
handle_exception(e) # this could be dangerous? Infinite looping?
133+
except SystemExit:
134+
pass
135+
# PATCH: Patched to continue on keyboard interrupt
136+
except KeyboardInterrupt:
137+
from harbor_cli.output.console import err_console
138+
139+
# User likely pressed Ctrl+C during a prompt or when a spinner
140+
# was active. Ensure message is printed on a new line.
141+
# TODO: determine if last char in terminal was newline somehow! Can we?
142+
err_console.print("\n[red]Aborted.[/]")
143+
pass
144+
145+
with patcher("click_repl.repl"):
146+
click_repl.repl = repl
147+
148+
149+
def patch() -> None:
150+
"""Apply all patches."""
151+
patch_exception_handling()

harbor_cli/_patches/common.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from __future__ import annotations
2+
3+
from abc import ABC
4+
from abc import abstractmethod
5+
from typing import TYPE_CHECKING
6+
7+
if TYPE_CHECKING:
8+
from types import TracebackType
9+
from typing import Optional
10+
from typing import Type
11+
12+
13+
class BasePatcher(ABC):
14+
"""Context manager that logs and prints diagnostic info if an exception
15+
occurs."""
16+
17+
def __init__(self, description: str) -> None:
18+
self.description = description
19+
20+
@abstractmethod
21+
def __package_info__(self) -> str:
22+
raise NotImplementedError
23+
24+
def __enter__(self) -> BasePatcher:
25+
return self
26+
27+
def __exit__(
28+
self,
29+
exc_type: Optional[Type[BaseException]],
30+
exc_val: Optional[BaseException],
31+
exc_tb: Optional[TracebackType],
32+
) -> bool:
33+
if not exc_type:
34+
return True
35+
import sys
36+
37+
import rich
38+
from rich.table import Table
39+
40+
from harbor_cli.__about__ import __version__
41+
42+
# Rudimentary, but provides enough info to debug and fix the issue
43+
console = rich.console.Console(stderr=True)
44+
console.print_exception()
45+
console.print()
46+
table = Table(
47+
title="Diagnostics",
48+
show_header=False,
49+
show_lines=False,
50+
)
51+
table.add_row(
52+
"[b]Package [/]",
53+
self.__package_info__(),
54+
)
55+
table.add_row(
56+
"[b]zabbix-cli [/]",
57+
__version__,
58+
)
59+
table.add_row(
60+
"[b]Python [/]",
61+
sys.version,
62+
)
63+
table.add_row(
64+
"[b]Platform [/]",
65+
sys.platform,
66+
)
67+
console.print(table)
68+
console.print(f"[bold red]ERROR: Failed to patch {self.description}[/]")
69+
return True # suppress exception
70+
71+
72+
def get_patcher(info: str) -> Type[BasePatcher]:
73+
"""Returns a patcher for a given package."""
74+
75+
class Patcher(BasePatcher):
76+
def __package_info__(self) -> str:
77+
return info
78+
79+
return Patcher

harbor_cli/_patches/typer.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Monkeypatches Typer to extend certain functionality and change the
2+
styling of its output.
3+
4+
Will probably break for some version of Typer at some point."""
5+
6+
from __future__ import annotations
7+
8+
import inspect
9+
from typing import Iterable
10+
from typing import Union
11+
12+
import click
13+
import typer
14+
15+
from harbor_cli._patches.common import get_patcher
16+
17+
patcher = get_patcher(f"Typer version: {typer.__version__}")
18+
19+
20+
def patch_help_text_style() -> None:
21+
"""Remove dimming of help text.
22+
23+
https://github.com/tiangolo/typer/issues/437#issuecomment-1224149402
24+
"""
25+
with patcher("typer.rich_utils.STYLE_HELPTEXT"):
26+
typer.rich_utils.STYLE_HELPTEXT = "" # type: ignore
27+
28+
29+
def patch_help_text_spacing() -> None:
30+
"""Adds a single blank line between short and long help text of a command
31+
when using `--help`.
32+
33+
As of Typer 0.9.0, the short and long help text is printed without any
34+
blank lines between them. This is bad for readability (IMO).
35+
"""
36+
from rich.console import group
37+
from rich.markdown import Markdown
38+
from rich.text import Text
39+
from typer.rich_utils import DEPRECATED_STRING
40+
from typer.rich_utils import MARKUP_MODE_MARKDOWN
41+
from typer.rich_utils import MARKUP_MODE_RICH
42+
from typer.rich_utils import STYLE_DEPRECATED
43+
from typer.rich_utils import STYLE_HELPTEXT
44+
from typer.rich_utils import STYLE_HELPTEXT_FIRST_LINE
45+
from typer.rich_utils import MarkupMode
46+
from typer.rich_utils import _make_rich_rext # type: ignore
47+
48+
@group()
49+
def _get_help_text(
50+
*,
51+
obj: Union[click.Command, click.Group],
52+
markup_mode: MarkupMode,
53+
) -> Iterable[Union[Markdown, Text]]:
54+
"""Build primary help text for a click command or group.
55+
56+
Returns the prose help text for a command or group, rendered either as a
57+
Rich Text object or as Markdown.
58+
If the command is marked as deprecated, the deprecated string will be prepended.
59+
"""
60+
# Prepend deprecated status
61+
if obj.deprecated:
62+
yield Text(DEPRECATED_STRING, style=STYLE_DEPRECATED)
63+
64+
# Fetch and dedent the help text
65+
help_text = inspect.cleandoc(obj.help or "")
66+
67+
# Trim off anything that comes after \f on its own line
68+
help_text = help_text.partition("\f")[0]
69+
70+
# Get the first paragraph
71+
first_line = help_text.split("\n\n")[0]
72+
# Remove single linebreaks
73+
if markup_mode != MARKUP_MODE_MARKDOWN and not first_line.startswith("\b"):
74+
first_line = first_line.replace("\n", " ")
75+
yield _make_rich_rext(
76+
text=first_line.strip(),
77+
style=STYLE_HELPTEXT_FIRST_LINE,
78+
markup_mode=markup_mode,
79+
)
80+
81+
# Get remaining lines, remove single line breaks and format as dim
82+
remaining_paragraphs = help_text.split("\n\n")[1:]
83+
if remaining_paragraphs:
84+
if markup_mode != MARKUP_MODE_RICH:
85+
# Remove single linebreaks
86+
remaining_paragraphs = [
87+
(
88+
x.replace("\n", " ").strip()
89+
if not x.startswith("\b")
90+
else "{}\n".format(x.strip("\b\n"))
91+
)
92+
for x in remaining_paragraphs
93+
]
94+
# Join back together
95+
remaining_lines = "\n".join(remaining_paragraphs)
96+
else:
97+
# Join with double linebreaks if markdown
98+
remaining_lines = "\n\n".join(remaining_paragraphs)
99+
yield _make_rich_rext(
100+
text="\n",
101+
style=STYLE_HELPTEXT,
102+
markup_mode=markup_mode,
103+
)
104+
yield _make_rich_rext(
105+
text=remaining_lines,
106+
style=STYLE_HELPTEXT,
107+
markup_mode=markup_mode,
108+
)
109+
110+
with patcher("typer.rich_utils._get_help_text"):
111+
typer.rich_utils._get_help_text = _get_help_text # type: ignore
112+
113+
114+
def patch() -> None:
115+
"""Apply all patches."""
116+
patch_help_text_style()
117+
patch_help_text_spacing()

0 commit comments

Comments
 (0)