Skip to content

Commit

Permalink
Fix coloring of sub-field dict-lookups in version string
Browse files Browse the repository at this point in the history
  • Loading branch information
kdeldycke committed Sep 4, 2023
1 parent 04701bf commit c5a7271
Show file tree
Hide file tree
Showing 5 changed files with 98 additions and 72 deletions.
9 changes: 6 additions & 3 deletions click_extra/commands.py
Expand Up @@ -377,10 +377,13 @@ def invoke(self, ctx: click.Context) -> Any:
)
if version_opt:
# Build a message exposing all available variables.
all_vars = {f"{{{{{var}}}}}": f"{{{var}}}" for var in ExtraVersionOption.template_keys}
max_len = max(map(len, all_vars.keys()))
all_fields = {f"{{{{{field_id}}}}}": f"{{{field_id}}}" for field_id in ExtraVersionOption.template_fields}

Check failure on line 380 in click_extra/commands.py

View workflow job for this annotation

GitHub Actions / lint / lint-python

Ruff (E501)

click_extra/commands.py:380:89: E501 Line too long (122 > 88 characters)
max_len = max(map(len, all_fields))
raw_format = "\n".join(f"{k:<{max_len}}: {v}" for k, v in all_fields.items())

Check failure on line 382 in click_extra/commands.py

View workflow job for this annotation

GitHub Actions / lint / lint-python

Ruff (E501)

click_extra/commands.py:382:89: E501 Line too long (93 > 88 characters)
msg = version_opt.render_message(
"\n".join(f"{k:<{max_len}}: {v}" for k, v in all_vars.items()),
version_opt.colored_template(
raw_format
),
)
logger.debug("Version string template variables:")
for line in msg.splitlines():
Expand Down
12 changes: 9 additions & 3 deletions click_extra/tests/test_version.py
Expand Up @@ -100,6 +100,12 @@ def color_cli2():
r"\x1b\[97mclick_extra"
r"\x1b\[0m\n",
),
(
"{prog_name}, version {version} (Python {env_info[python][version]})",
r"\x1b\[97mcolor-cli3\x1b\[0m, "
rf"version \x1b\[32m{re.escape(__version__)}\x1b\[0m "
r"\(Python \x1b\[90m3\.\d+\.\d+ .+\x1b\[0m\)\n",
),
),
)
def test_custom_message(invoke, cmd_decorator, message, regex_stdout):
Expand Down Expand Up @@ -160,9 +166,9 @@ def test_context_meta(invoke, cmd_decorator):
@extra_version_option
@pass_context
def version_metadata(ctx):
for var in ExtraVersionOption.template_keys:
value = ctx.meta[f"click_extra.{var}"]
echo(f"{var} = {value}")
for field in ExtraVersionOption.template_fields:
value = ctx.meta[f"click_extra.{field}"]
echo(f"{field} = {value}")

result = invoke(version_metadata, color=True)
assert result.exit_code == 0
Expand Down
125 changes: 59 additions & 66 deletions click_extra/version.py
Expand Up @@ -24,11 +24,10 @@
from gettext import gettext as _
from importlib import metadata
from typing import TYPE_CHECKING, cast
from string import Formatter

import click
from boltons.ecoutils import get_profile
from boltons.formatutils import get_format_args, tokenize_format_str, BaseFormatField
from boltons.formatutils import tokenize_format_str, BaseFormatField

from . import Context, Parameter, Style, echo, get_current_context
from .colorize import default_theme
Expand Down Expand Up @@ -56,12 +55,15 @@ class ExtraVersionOption(ExtraOption):
- `click#2331 <https://github.com/pallets/click/issues/2331>`_,
by distingushing the module from the package.
- `click#1756 <https://github.com/pallets/click/issues/1756>`_,
by allowing path and Python version.
"""

message: str = _("{prog_name}, version {version}")
"""Default message template used to render the version string."""

template_keys: tuple[str] = (
template_fields: tuple[str] = (
"module",
"module_name",
"module_file",
Expand All @@ -73,13 +75,13 @@ class ExtraVersionOption(ExtraOption):
"prog_name",
"env_info",
)
"""List of variables available for the message template."""
"""List of field IDs recognized by the message template."""

def __init__(
self,
param_decls: Sequence[str] | None = None,
message: str | None = None,
# Variable ovverrides.
# Field value ovverrides.
module: str | None = None,
module_name: str | None = None,
module_file: str | None = None,
Expand All @@ -90,7 +92,7 @@ def __init__(
version: str | None = None,
prog_name: str | None = None,
env_info: dict[str, str] | None = None,
# Variable's styles.
# Field style ovverrides.
message_style: IStyle | None = None,
module_style: IStyle | None = None,
module_name_style: IStyle | None = default_theme.invoked_command,
Expand All @@ -108,11 +110,9 @@ def __init__(
help=_("Show the version and exit."),
**kwargs,
) -> None:
"""Preconfigured ``--version`` option.
Immediately prints the version number and exits the CLI.
"""Preconfigured as a ``--version`` option flag.
:param message: the message template to print, in `Format String syntax
:param message: the message template to print, in `format string syntax
<https://docs.python.org/3/library/string.html#format-string-syntax>`_.
Defaults to ``{prog_name}, version {version}``.
Expand Down Expand Up @@ -146,34 +146,19 @@ def __init__(
if message is not None:
self.message = message

# Validates the message template.
pos_args, var_args = get_format_args(self.message)
if pos_args:
msg = (
"Positional arguments are not allowed in the message template. "
f"Found: {pos_args!r}"
)
raise ValueError(msg)
unknown_vars = {v for v, _ in var_args}.difference(self.template_keys)
if unknown_vars:
msg = (
f"Unknown variables in the message template: {unknown_vars}"
)
raise ValueError(msg)

self.message_style = message_style

# Overrides default variable and their styles with user-provided values.
for var in self.template_keys:
# Overrides default field's value and style with user-provided parameters.
for field_id in self.template_fields:

# Set variable values.
var_value = locals().get(var)
if var_value is not None:
setattr(self, var, var_value)
# Override field value.
user_value = locals().get(field_id)
if user_value is not None:
setattr(self, field_id, user_value)

# Set variable styles.
style_name = f"{var}_style"
setattr(self, style_name, locals()[style_name])
# Set field style.
style_id = f"{field_id}_style"
setattr(self, style_id, locals()[style_id])

kwargs.setdefault("callback", self.print_and_exit)

Expand Down Expand Up @@ -361,66 +346,74 @@ def env_info(self) -> dict[str, str]:
"""
return cast("dict[str, str]", get_profile(scrub=True))

def colorize_default_segments(self, template: str) -> str:
"""Colorize the literal parts of the template with the default style.
def colored_template(self, template: str | None = None) -> str:
"""Insert ANSI styles to a message template.
Accepts a custom ``template`` as parameter, otherwise uses the default message
defined on the Option instance.
This step is necessary because ANSI codes are linear and cannot be encapsulated.
This step is necessary because we need to linearize the template to apply the
ANSI codes on the string segments. This is a consequence of the nature of ANSI,
directives which cannot be encapsulated within another (unlike markup tags like HTML).

Check failure on line 357 in click_extra/version.py

View workflow job for this annotation

GitHub Actions / lint / lint-python

Ruff (E501)

click_extra/version.py:357:89: E501 Line too long (94 > 88 characters)
"""
# A copy of the template in which all literal parts are colored with the default style.
colored_default = ""
if template is None:
template = self.message

# Normalize the default to a no-op Style() callable to simplify the code of the colorization step.

Check failure on line 362 in click_extra/version.py

View workflow job for this annotation

GitHub Actions / lint / lint-python

Ruff (E501)

click_extra/version.py:362:89: E501 Line too long (106 > 88 characters)
def noop(s: str) -> str:
return s
default_style = self.message_style if self.message_style else noop

# Associate each field with its own style.
field_styles = {}
for field_id in self.template_fields:
field_style = getattr(self, f"{field_id}_style")
# If no style is defined for this field, use the default style of the message.

Check failure on line 371 in click_extra/version.py

View workflow job for this annotation

GitHub Actions / lint / lint-python

Ruff (E501)

click_extra/version.py:371:89: E501 Line too long (90 > 88 characters)
if not field_style:
field_style = default_style
field_styles[field_id] = field_style

# A copy of the template, where literals and fields segments are colored.
colored_template = ""

# Semantic split of the template into fields and literals.
# Split the template semantically between fields and literals.
accumulated_literals = ""
for segment in tokenize_format_str(template):
for segment in tokenize_format_str(template, resolve_pos=False):
# Format field.
if isinstance(segment, BaseFormatField):

# Dump the accumulated literal string to the template copy, and reset it.

Check failure on line 385 in click_extra/version.py

View workflow job for this annotation

GitHub Actions / lint / lint-python

Ruff (E501)

click_extra/version.py:385:89: E501 Line too long (89 > 88 characters)
if accumulated_literals:
# Colorize the literal string with the default style.
colored_default += self.message_style(accumulated_literals)
colored_template += default_style(accumulated_literals)
accumulated_literals = ""

# Add the field to the template copy.
colored_default += str(segment)
# Add the field to the template copy, colored with its own style.
colored_template += field_styles[segment.base_name](str(segment))

# Keep accumulating literal strings until the next field.
else:
accumulated_literals += segment
# Escape the curly braces of the literal string.
accumulated_literals += segment.replace('{', '{{').replace('}', '}}')

# Dump the accumulated literal string to the template copy, and reset it.
if accumulated_literals:
# Colorize the literal string with the default style.
colored_default += self.message_style(accumulated_literals)
colored_template += default_style(accumulated_literals)
accumulated_literals = ""


return colored_default
return colored_template

def render_message(self, template: str | None = None) -> str:
"""Render the version string from the provided template.
Accepts a custom ``template`` as parameter, otherwise uses the default
``self.message`` defined on the instance.
``self.colored_template()`` produced by the instance.
"""
if template is None:
template = self.message

if self.message_style:
template = self.colorize_default_segments(template)

# Colorize each variable with its own style.
colored_vars = {}
for var in self.template_keys:
var_value = getattr(self, var)
# Get the style function for this part, defaults to `self.message_style`.
var_style = getattr(self, f"{var}_style")
if not var_style:
var_style = self.message_style
# Apply the style function if any.
colored_vars[var] = var_style(var_value) if var_style else var_value
template = self.colored_template()

return template.format(**colored_vars)
return template.format(**{v: getattr(self, v) for v in self.template_fields})

def print_and_exit(
self,
Expand All @@ -433,7 +426,7 @@ def print_and_exit(
Also stores all version string elements in the Context's ``meta`` `dict`.
"""
# Populate the context's meta dict with the version string elements.
for var in self.template_keys:
for var in self.template_fields:
ctx.meta[f"click_extra.{var}"] = getattr(self, var)

if not value or ctx.resilient_parsing:
Expand Down
1 change: 1 addition & 0 deletions docs/issues.md
Expand Up @@ -21,6 +21,7 @@ Here is the list of issues and bugs from other projects that `click-extra` has a
- [`#2207` - Support `NO_COLOR` environment variable](https://github.com/pallets/click/issues/2207)
- [`#2111` - `Context.color = False` doesn't overrides `echo(color=True)`](https://github.com/pallets/click/issues/2111)
- [`#2110` - `testing.CliRunner.invoke` cannot pass color for `Context` instantiation](https://github.com/pallets/click/issues/2110)
- [`#1756` - Path and Python version for version message formatting](https://github.com/pallets/click/issues/1756)
- [`#1498` - Support for `NO_COLOR` proposal](https://github.com/pallets/click/issues/1498)
- [`#1279` - Provide access to a normalized list of args](https://github.com/pallets/click/issues/1279)
- [`#1090` - Color output from CI jobs](https://github.com/pallets/click/issues/1090)
Expand Down
23 changes: 23 additions & 0 deletions docs/version.md
Expand Up @@ -264,6 +264,29 @@ It's verbose but it's helpful for debugging and reporting of issues from end use
The JSON output is scrubbed out of identifiable information by default: current working directory, hostname, Python executable path, command-line arguments and username are replaced with `-`.
```

Another trick consist in picking into the content of `{env_info}` to produce highly customized version strings. This can be done because `{env_info}` is kept as a `dict`:

```{eval-rst}
.. click:example::
from click_extra import command, extra_version_option
@command
@extra_version_option(
message="{prog_name} {version}, from {module_file} (Python {env_info[python][version]})"
)
def custom_env_info():
pass
.. click:run::
import re
from click_extra import __version__
result = invoke(custom_env_info, args=["--version"])
assert re.fullmatch((
rf"\x1b\[97mcustom-env-info\x1b\[0m \x1b\[32m{__version__}\x1b\[0m, "
r"from .+ \(Python \x1b\[90m3\.\d+\.\d+ .+\x1b\[0m\)\n"
), result.output)
```

## Debug logs

When the `DEBUG` level is enabled, all available variables will be printed in the log:
Expand Down

0 comments on commit c5a7271

Please sign in to comment.