qq_lib.core.click_format
GNU-style help formatting for Click commands.
This module defines GNUHelpColorsCommand, a Click command class that prints
help text using GNU-style formatting with customizable colors, headings, and
option layouts.
1# Released under MIT License. 2# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab 3 4""" 5GNU-style help formatting for Click commands. 6 7This module defines `GNUHelpColorsCommand`, a Click command class that prints 8help text using GNU-style formatting with customizable colors, headings, and 9option layouts. 10""" 11 12from collections.abc import Sequence 13from pathlib import Path 14 15import click 16from click import Context, HelpFormatter 17from click_help_colors import HelpColorsCommand 18 19 20class GNUHelpColorsCommand(HelpColorsCommand): 21 """Custom formatter that prints options in GNU-style.""" 22 23 def get_help(self, ctx: Context) -> str: 24 class GNUHelpFormatter(HelpFormatter): 25 def __init__(self, width=None, headers_color=None, options_color=None): 26 super().__init__(width=width) 27 self.headers_color = headers_color or "white" 28 self.options_color = options_color or "white" 29 30 def write_heading(self, heading: str) -> None: 31 styled_heading = click.style(heading, fg=self.headers_color, bold=True) 32 self.write(f"{styled_heading}\n") 33 34 def write_usage( 35 self, prog_name: str, args: str | None, prefix: str | None = None 36 ) -> None: # ty: ignore[invalid-method-override] 37 """Override to make Usage: header bold""" 38 if prefix is None: 39 prefix = "Usage:" 40 41 styled_prefix = click.style(prefix, fg=self.headers_color, bold=True) 42 usage_line = f"{styled_prefix} {prog_name}" 43 44 if args: 45 usage_line += f" {args}" 46 47 self.write(f"{usage_line}\n") 48 49 def write_dl( 50 self, 51 rows: Sequence[tuple[str, str | None]], 52 _col_max: int = 30, 53 _col_spacing: int = 2, 54 ) -> None: # ty: ignore[invalid-method-override] 55 for term, definition in rows: 56 colored_term = click.style(term, fg=self.options_color, bold=True) 57 self.write(f" {colored_term}\n") 58 59 if definition: 60 for line in definition.splitlines(): 61 if line.strip(): 62 self.write(f" {line}\n") 63 self.write("\n") 64 65 formatter = GNUHelpFormatter( 66 width=ctx.terminal_width, 67 headers_color=getattr(self, "help_headers_color", "white"), 68 options_color=getattr(self, "help_options_color", "white"), 69 ) 70 71 self.format_help(ctx, formatter) 72 return formatter.getvalue() 73 74 def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: 75 """ 76 Reassemble bash-split option tokens before delegating to Click's parser. 77 78 Bash splits tokens on `=` (which appears in `COMP_WORDBREAKS` by 79 default) before invoking the completion handler. This means that an 80 option like ``--option=foo=bar`` arrives as the fragments 81 `["--option", "=", "foo", "=", "bar"]` rather than as a single token. 82 Click's parser cannot handle this fragmented form, so autocompletion 83 breaks whenever an option value contains `=`. 84 85 This override reassembles such fragments back into `["--option", 86 "foo=bar"]` during resilient (completion) parsing, leaving normal 87 invocations entirely unaffected. 88 89 Args: 90 ctx: The current Click context. 91 args: The raw argument list as received from the shell, potentially 92 containing `=`-fragmented option tokens produced by bash's 93 completion machinery. 94 95 Returns: 96 The remaining unparsed arguments, after reassembly and delegation 97 to the parent parser. 98 """ 99 100 if ctx.resilient_parsing: 101 processed = [] 102 i = 0 103 while i < len(args): 104 arg = args[i] 105 if arg.startswith("-") and "=" in arg: 106 # already combined: --option=foo=bar 107 option, value = arg.split("=", 1) 108 processed.append(option) 109 if value: 110 processed.append(value) 111 i += 1 112 elif arg.startswith("-") and i + 1 < len(args) and args[i + 1] == "=": 113 # bash pre-split starting with "=": ["--opt", "=", "foo", "=", "bar"] 114 processed.append(arg) 115 i += 2 # skip option and bare "=" 116 value_parts = [] 117 while i < len(args) and not args[i].startswith("-"): 118 if args[i] == "=": 119 value_parts.append("=") 120 else: 121 if value_parts and value_parts[-1] != "=": 122 break # previous wasn't "=", this is a new arg 123 value_parts.append(args[i]) 124 i += 1 125 if value_parts: 126 processed.append("".join(value_parts)) 127 elif ( 128 arg.startswith("-") 129 and i + 1 < len(args) 130 and not args[i + 1].startswith("-") 131 and i + 2 < len(args) 132 and args[i + 2] == "=" 133 ): 134 # bash pre-split starting with fragment: ["--opt", "foo", "=", "bar"] 135 processed.append(arg) 136 i += 1 137 value_parts = [args[i]] 138 i += 1 139 while i < len(args) and not args[i].startswith("-"): 140 if args[i] == "=": 141 value_parts.append("=") 142 i += 1 143 if i < len(args) and not args[i].startswith("-"): 144 value_parts.append(args[i]) 145 i += 1 146 else: 147 break 148 processed.append("".join(value_parts)) 149 else: 150 processed.append(arg) 151 i += 1 152 args = processed 153 return super().parse_args(ctx, args) 154 155 156class GlobDirectoryMixin: 157 """ 158 Mixin that adds glob-expanding multi-value support for -d/--directory. 159 160 Click does not support nargs=-1 on options. This mixin pre-processes the 161 argument list before Click's parser sees it, greedily consuming all 162 non-flag tokens following -d/--directory, glob-expanding each one, and 163 rewriting them as repeated -d <path> pairs that Click's multiple=True 164 parser handles correctly. 165 166 Must appear before the Click command class in the MRO so that its 167 parse_args runs first. 168 """ 169 170 def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: 171 """ 172 Rewrite -d/--dir tokens to support multiple values and globs. 173 174 Greedily consumes all non-flag tokens following a -d/--dir flag, 175 glob-expands each one relative to its own parent directory, and emits 176 one -d <path> pair per expanded result. 177 178 Args: 179 ctx (click.Context): The current Click context. 180 args (list[str]): The raw argument list. 181 182 Returns: 183 list[str]: The remaining unparsed arguments after delegation to the 184 parent parser. 185 """ 186 rewritten: list[str] = [] 187 i = 0 188 while i < len(args): 189 if args[i] in ("-d", "--dir"): 190 i += 1 191 while i < len(args) and not args[i].startswith("-"): 192 pattern = args[i] 193 p = Path(pattern) 194 if not p.name: 195 # pattern is a bare directory (e.g. "." or "/some/dir/") - no glob needed 196 rewritten.extend(["-d", pattern]) 197 else: 198 expanded = sorted(p.parent.glob(p.name)) 199 if expanded: 200 for path in expanded: 201 rewritten.extend(["-d", str(path)]) 202 else: 203 rewritten.extend(["-d", pattern]) 204 i += 1 205 else: 206 rewritten.append(args[i]) 207 i += 1 208 return super().parse_args(ctx, rewritten) # ty:ignore[unresolved-attribute] 209 210 211class QQOperatorCommand(GlobDirectoryMixin, GNUHelpColorsCommand): 212 """GNUHelpColorsCommand with glob-expanding -d/--dir support.""" 213 214 pass
21class GNUHelpColorsCommand(HelpColorsCommand): 22 """Custom formatter that prints options in GNU-style.""" 23 24 def get_help(self, ctx: Context) -> str: 25 class GNUHelpFormatter(HelpFormatter): 26 def __init__(self, width=None, headers_color=None, options_color=None): 27 super().__init__(width=width) 28 self.headers_color = headers_color or "white" 29 self.options_color = options_color or "white" 30 31 def write_heading(self, heading: str) -> None: 32 styled_heading = click.style(heading, fg=self.headers_color, bold=True) 33 self.write(f"{styled_heading}\n") 34 35 def write_usage( 36 self, prog_name: str, args: str | None, prefix: str | None = None 37 ) -> None: # ty: ignore[invalid-method-override] 38 """Override to make Usage: header bold""" 39 if prefix is None: 40 prefix = "Usage:" 41 42 styled_prefix = click.style(prefix, fg=self.headers_color, bold=True) 43 usage_line = f"{styled_prefix} {prog_name}" 44 45 if args: 46 usage_line += f" {args}" 47 48 self.write(f"{usage_line}\n") 49 50 def write_dl( 51 self, 52 rows: Sequence[tuple[str, str | None]], 53 _col_max: int = 30, 54 _col_spacing: int = 2, 55 ) -> None: # ty: ignore[invalid-method-override] 56 for term, definition in rows: 57 colored_term = click.style(term, fg=self.options_color, bold=True) 58 self.write(f" {colored_term}\n") 59 60 if definition: 61 for line in definition.splitlines(): 62 if line.strip(): 63 self.write(f" {line}\n") 64 self.write("\n") 65 66 formatter = GNUHelpFormatter( 67 width=ctx.terminal_width, 68 headers_color=getattr(self, "help_headers_color", "white"), 69 options_color=getattr(self, "help_options_color", "white"), 70 ) 71 72 self.format_help(ctx, formatter) 73 return formatter.getvalue() 74 75 def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: 76 """ 77 Reassemble bash-split option tokens before delegating to Click's parser. 78 79 Bash splits tokens on `=` (which appears in `COMP_WORDBREAKS` by 80 default) before invoking the completion handler. This means that an 81 option like ``--option=foo=bar`` arrives as the fragments 82 `["--option", "=", "foo", "=", "bar"]` rather than as a single token. 83 Click's parser cannot handle this fragmented form, so autocompletion 84 breaks whenever an option value contains `=`. 85 86 This override reassembles such fragments back into `["--option", 87 "foo=bar"]` during resilient (completion) parsing, leaving normal 88 invocations entirely unaffected. 89 90 Args: 91 ctx: The current Click context. 92 args: The raw argument list as received from the shell, potentially 93 containing `=`-fragmented option tokens produced by bash's 94 completion machinery. 95 96 Returns: 97 The remaining unparsed arguments, after reassembly and delegation 98 to the parent parser. 99 """ 100 101 if ctx.resilient_parsing: 102 processed = [] 103 i = 0 104 while i < len(args): 105 arg = args[i] 106 if arg.startswith("-") and "=" in arg: 107 # already combined: --option=foo=bar 108 option, value = arg.split("=", 1) 109 processed.append(option) 110 if value: 111 processed.append(value) 112 i += 1 113 elif arg.startswith("-") and i + 1 < len(args) and args[i + 1] == "=": 114 # bash pre-split starting with "=": ["--opt", "=", "foo", "=", "bar"] 115 processed.append(arg) 116 i += 2 # skip option and bare "=" 117 value_parts = [] 118 while i < len(args) and not args[i].startswith("-"): 119 if args[i] == "=": 120 value_parts.append("=") 121 else: 122 if value_parts and value_parts[-1] != "=": 123 break # previous wasn't "=", this is a new arg 124 value_parts.append(args[i]) 125 i += 1 126 if value_parts: 127 processed.append("".join(value_parts)) 128 elif ( 129 arg.startswith("-") 130 and i + 1 < len(args) 131 and not args[i + 1].startswith("-") 132 and i + 2 < len(args) 133 and args[i + 2] == "=" 134 ): 135 # bash pre-split starting with fragment: ["--opt", "foo", "=", "bar"] 136 processed.append(arg) 137 i += 1 138 value_parts = [args[i]] 139 i += 1 140 while i < len(args) and not args[i].startswith("-"): 141 if args[i] == "=": 142 value_parts.append("=") 143 i += 1 144 if i < len(args) and not args[i].startswith("-"): 145 value_parts.append(args[i]) 146 i += 1 147 else: 148 break 149 processed.append("".join(value_parts)) 150 else: 151 processed.append(arg) 152 i += 1 153 args = processed 154 return super().parse_args(ctx, args)
Custom formatter that prints options in GNU-style.
24 def get_help(self, ctx: Context) -> str: 25 class GNUHelpFormatter(HelpFormatter): 26 def __init__(self, width=None, headers_color=None, options_color=None): 27 super().__init__(width=width) 28 self.headers_color = headers_color or "white" 29 self.options_color = options_color or "white" 30 31 def write_heading(self, heading: str) -> None: 32 styled_heading = click.style(heading, fg=self.headers_color, bold=True) 33 self.write(f"{styled_heading}\n") 34 35 def write_usage( 36 self, prog_name: str, args: str | None, prefix: str | None = None 37 ) -> None: # ty: ignore[invalid-method-override] 38 """Override to make Usage: header bold""" 39 if prefix is None: 40 prefix = "Usage:" 41 42 styled_prefix = click.style(prefix, fg=self.headers_color, bold=True) 43 usage_line = f"{styled_prefix} {prog_name}" 44 45 if args: 46 usage_line += f" {args}" 47 48 self.write(f"{usage_line}\n") 49 50 def write_dl( 51 self, 52 rows: Sequence[tuple[str, str | None]], 53 _col_max: int = 30, 54 _col_spacing: int = 2, 55 ) -> None: # ty: ignore[invalid-method-override] 56 for term, definition in rows: 57 colored_term = click.style(term, fg=self.options_color, bold=True) 58 self.write(f" {colored_term}\n") 59 60 if definition: 61 for line in definition.splitlines(): 62 if line.strip(): 63 self.write(f" {line}\n") 64 self.write("\n") 65 66 formatter = GNUHelpFormatter( 67 width=ctx.terminal_width, 68 headers_color=getattr(self, "help_headers_color", "white"), 69 options_color=getattr(self, "help_options_color", "white"), 70 ) 71 72 self.format_help(ctx, formatter) 73 return formatter.getvalue()
Formats the help into a string and returns it.
Calls format_help() internally.
75 def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: 76 """ 77 Reassemble bash-split option tokens before delegating to Click's parser. 78 79 Bash splits tokens on `=` (which appears in `COMP_WORDBREAKS` by 80 default) before invoking the completion handler. This means that an 81 option like ``--option=foo=bar`` arrives as the fragments 82 `["--option", "=", "foo", "=", "bar"]` rather than as a single token. 83 Click's parser cannot handle this fragmented form, so autocompletion 84 breaks whenever an option value contains `=`. 85 86 This override reassembles such fragments back into `["--option", 87 "foo=bar"]` during resilient (completion) parsing, leaving normal 88 invocations entirely unaffected. 89 90 Args: 91 ctx: The current Click context. 92 args: The raw argument list as received from the shell, potentially 93 containing `=`-fragmented option tokens produced by bash's 94 completion machinery. 95 96 Returns: 97 The remaining unparsed arguments, after reassembly and delegation 98 to the parent parser. 99 """ 100 101 if ctx.resilient_parsing: 102 processed = [] 103 i = 0 104 while i < len(args): 105 arg = args[i] 106 if arg.startswith("-") and "=" in arg: 107 # already combined: --option=foo=bar 108 option, value = arg.split("=", 1) 109 processed.append(option) 110 if value: 111 processed.append(value) 112 i += 1 113 elif arg.startswith("-") and i + 1 < len(args) and args[i + 1] == "=": 114 # bash pre-split starting with "=": ["--opt", "=", "foo", "=", "bar"] 115 processed.append(arg) 116 i += 2 # skip option and bare "=" 117 value_parts = [] 118 while i < len(args) and not args[i].startswith("-"): 119 if args[i] == "=": 120 value_parts.append("=") 121 else: 122 if value_parts and value_parts[-1] != "=": 123 break # previous wasn't "=", this is a new arg 124 value_parts.append(args[i]) 125 i += 1 126 if value_parts: 127 processed.append("".join(value_parts)) 128 elif ( 129 arg.startswith("-") 130 and i + 1 < len(args) 131 and not args[i + 1].startswith("-") 132 and i + 2 < len(args) 133 and args[i + 2] == "=" 134 ): 135 # bash pre-split starting with fragment: ["--opt", "foo", "=", "bar"] 136 processed.append(arg) 137 i += 1 138 value_parts = [args[i]] 139 i += 1 140 while i < len(args) and not args[i].startswith("-"): 141 if args[i] == "=": 142 value_parts.append("=") 143 i += 1 144 if i < len(args) and not args[i].startswith("-"): 145 value_parts.append(args[i]) 146 i += 1 147 else: 148 break 149 processed.append("".join(value_parts)) 150 else: 151 processed.append(arg) 152 i += 1 153 args = processed 154 return super().parse_args(ctx, args)
Reassemble bash-split option tokens before delegating to Click's parser.
Bash splits tokens on = (which appears in COMP_WORDBREAKS by
default) before invoking the completion handler. This means that an
option like --option=foo=bar arrives as the fragments
["--option", "=", "foo", "=", "bar"] rather than as a single token.
Click's parser cannot handle this fragmented form, so autocompletion
breaks whenever an option value contains =.
This override reassembles such fragments back into ["--option",
"foo=bar"] during resilient (completion) parsing, leaving normal
invocations entirely unaffected.
Arguments:
- ctx: The current Click context.
- args: The raw argument list as received from the shell, potentially
containing
=-fragmented option tokens produced by bash's completion machinery.
Returns:
The remaining unparsed arguments, after reassembly and delegation to the parent parser.
157class GlobDirectoryMixin: 158 """ 159 Mixin that adds glob-expanding multi-value support for -d/--directory. 160 161 Click does not support nargs=-1 on options. This mixin pre-processes the 162 argument list before Click's parser sees it, greedily consuming all 163 non-flag tokens following -d/--directory, glob-expanding each one, and 164 rewriting them as repeated -d <path> pairs that Click's multiple=True 165 parser handles correctly. 166 167 Must appear before the Click command class in the MRO so that its 168 parse_args runs first. 169 """ 170 171 def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: 172 """ 173 Rewrite -d/--dir tokens to support multiple values and globs. 174 175 Greedily consumes all non-flag tokens following a -d/--dir flag, 176 glob-expands each one relative to its own parent directory, and emits 177 one -d <path> pair per expanded result. 178 179 Args: 180 ctx (click.Context): The current Click context. 181 args (list[str]): The raw argument list. 182 183 Returns: 184 list[str]: The remaining unparsed arguments after delegation to the 185 parent parser. 186 """ 187 rewritten: list[str] = [] 188 i = 0 189 while i < len(args): 190 if args[i] in ("-d", "--dir"): 191 i += 1 192 while i < len(args) and not args[i].startswith("-"): 193 pattern = args[i] 194 p = Path(pattern) 195 if not p.name: 196 # pattern is a bare directory (e.g. "." or "/some/dir/") - no glob needed 197 rewritten.extend(["-d", pattern]) 198 else: 199 expanded = sorted(p.parent.glob(p.name)) 200 if expanded: 201 for path in expanded: 202 rewritten.extend(["-d", str(path)]) 203 else: 204 rewritten.extend(["-d", pattern]) 205 i += 1 206 else: 207 rewritten.append(args[i]) 208 i += 1 209 return super().parse_args(ctx, rewritten) # ty:ignore[unresolved-attribute]
Mixin that adds glob-expanding multi-value support for -d/--directory.
Click does not support nargs=-1 on options. This mixin pre-processes the
argument list before Click's parser sees it, greedily consuming all
non-flag tokens following -d/--directory, glob-expanding each one, and
rewriting them as repeated -d
Must appear before the Click command class in the MRO so that its parse_args runs first.
171 def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: 172 """ 173 Rewrite -d/--dir tokens to support multiple values and globs. 174 175 Greedily consumes all non-flag tokens following a -d/--dir flag, 176 glob-expands each one relative to its own parent directory, and emits 177 one -d <path> pair per expanded result. 178 179 Args: 180 ctx (click.Context): The current Click context. 181 args (list[str]): The raw argument list. 182 183 Returns: 184 list[str]: The remaining unparsed arguments after delegation to the 185 parent parser. 186 """ 187 rewritten: list[str] = [] 188 i = 0 189 while i < len(args): 190 if args[i] in ("-d", "--dir"): 191 i += 1 192 while i < len(args) and not args[i].startswith("-"): 193 pattern = args[i] 194 p = Path(pattern) 195 if not p.name: 196 # pattern is a bare directory (e.g. "." or "/some/dir/") - no glob needed 197 rewritten.extend(["-d", pattern]) 198 else: 199 expanded = sorted(p.parent.glob(p.name)) 200 if expanded: 201 for path in expanded: 202 rewritten.extend(["-d", str(path)]) 203 else: 204 rewritten.extend(["-d", pattern]) 205 i += 1 206 else: 207 rewritten.append(args[i]) 208 i += 1 209 return super().parse_args(ctx, rewritten) # ty:ignore[unresolved-attribute]
Rewrite -d/--dir tokens to support multiple values and globs.
Greedily consumes all non-flag tokens following a -d/--dir flag,
glob-expands each one relative to its own parent directory, and emits
one -d
Arguments:
- ctx (click.Context): The current Click context.
- args (list[str]): The raw argument list.
Returns:
list[str]: The remaining unparsed arguments after delegation to the parent parser.
212class QQOperatorCommand(GlobDirectoryMixin, GNUHelpColorsCommand): 213 """GNUHelpColorsCommand with glob-expanding -d/--dir support.""" 214 215 pass
GNUHelpColorsCommand with glob-expanding -d/--dir support.