feat: Support rich markup (instead of Markdown) in `HelpFormatter.default_format`?
Just like we can do:
output = cappa.Output(error_format="[bold]insiders[/]: [bold red]error[/]: {message}")
...I was wondering if it would make sense to allow such markup in HelpFormatter.default_format too, for example:
help_formatter = cappa.HelpFormatter(default_format="[dim italic]Default: {default}.[/]")
Note that the default value themselves should still be rendered as Markdown, for example:
cappa.Arg(
...,
show_default=f"`{defaults.DEFAULT_CONF_PATH}`",
)
The reason cappa.Output and cappa.HelpFormatter can use rich markup while show_default should only use Markdown is that the former two are exclusively displayed in the terminal, while the latter can be programmatically rendered into other formats (HTML page describing the CLI for example).
Not sure if it's possible to combine both. I mean it sounds like it should work if default values (show_default) are rendered in isolation as Markdown, then the whole default (formatter) is rendered in isolation as markup, but that might not be the case (both concatenated/formatted as a single string then rendered as Markdown).
This is somewhat more complicated because the default value string is being templated inside of a larger string. Or i guess not more complicated if you, for example, expected {message} to be markdown.
seems like it'll have to string format some sentinel default value into the string then split on the sentinel, yielding [Text(before), Markdown(default), Text(after)].
Although the same logic would apply to Arg.help, which I could see the case for being either markdown or markup preferentially, no?
I suppose it's reasonable to let the user choose between markup/markdown. That would make the code base more consistent (instead of having some text rendered as markdown and some other as markup). How would you detect one or the other though? Surely `[/]` should not be interpreted as markup in a markdown string.
Relevant: https://github.com/Textualize/rich/discussions/1249, https://github.com/Textualize/rich/discussions/1272.
I dont know that i would detect markdown/markup string-wise. moreso i think it would be fair to accept Text/Markdown objects where strings are currently allowed, and help/show_default would be wrapped in Markdown if a string.
The only way i can think of to compose this with rich's API as-is would be
- prepare the whole string formatted with stubs
"{help} {default}".format(help=uuid1, default=uuid2) - figure out a way of splitting the string on all delimiters while keeping track of their order
- Text each static string segment
- interpose each concrete value of whichever rich type it ends up being.
- profit???
I think after all that, the default behavior would remain consistent with how it works today, while still ultimately enabling what you're suggesting, and enabling more custom stuff like your Markdown "hello", style="dim")
Another relevant discussion: https://github.com/Textualize/rich/discussions/1951. See how the author manages to combine different renderables into one (Markdown and markup): https://github.com/ewels/rich-click/commit/6309974bfc9eda3424473cfaadf93cb73da7cb2e and https://github.com/ewels/rich-click/commit/a004ad47f6d18923f2c32cce33c53ccc80e55e17 using Columns and Text.from_markup.
format_arg already builds a list of segments, so maybe it could return that list, and add_long_args would wrap it in Columns (something like that).
I managed to get something working with the following:
help_formatter = cappa.HelpFormatter(
arg_format=(
"{help}",
"{choices}",
lambda default, **_: default and Markdown(default, style="dim italic"),
),
default_format="Default: {default}.",
)
In format_arg:
if callable(format_segment):
segment = format_segment(**context)
else:
segment = format_segment.format(**context)
if segment:
if isinstance(segment, str):
segment = Markdown(segment)
segments.append(segment)
return segments
...passing whole context to callable instead of just arg, as well as returning segments directly (without joining them), which are wrapped in Columns in add_long_args:
table.add_row(
Padding(format_arg_name(arg, ", "), help_formatter.left_padding),
Columns(format_arg(help_formatter, arg)),
)
(there might be a parameter on Columns that would prevent new lines between elements, see screenshot below)
Not bad :smile:
My solution might be too specific to my use-case. The UUID splitting approach might yield a more consistent API.
Anyway, let me know if you want a diff :slightly_smiling_face:
Got something working to prevent newlines: https://github.com/Textualize/rich/discussions/3603.
i'm imagining that it needs to format then split because of the possibility of arbitrary text before/after the actual format specifier. Per your original example: show_default="[dim italic]Default: {default}.[/]". It's the only thing that comes to mind without switching the existing API away from using format strings to easily compose the different parts of the arg string (which prior to this seemed like it was working quite well :P)
fwiw, i think it looks nice (maybe even better) for at least your particular set of screenshotted arguments on newlines, lol. Also kinda makes me want to set the default format to be dim by default.
Of course, whatever is best in terms of API and backwards compatibility βΊοΈ
Yeah dim by default is probably not a bad idea. I was finding it hard to read with long default values also using code quotes, hence trying to dim them.
About newlines vs no newlines, yeah, I'm unsure which one I prefer π
I went down somewhat of a rabbit hole on my original take, and i'm not sure it's feasible.
I guess...maybe HelpPart('{default}', cls=Markdown, style='dim light') and arg_format defaults are adjusted to use that format?
so your code would or could look something like
help_formatter = cappa.HelpFormatter(
default_part = HelpPart.default(style="dim italic")
)
and there'd arg_format would maybe be like... (HelpPart.help, HelpPart.choices, HelpPart.default), those as sentinels would invoke code to use the specific "default_part" value, and existing API uses of raw format strings simply wouldn't get custom segment sylability.
this is also very conceptual in my head and i'm not sure if i'm explaining it intelligbly or if it will work either
Yeah I thought this might prove complicated to support in a robust/elegant way. Honestly I'd be fine with leaving out the italic if you just end up dimming the default string by default. Otherwise the HelpPart suggestion seems good too.
I think i have something:
@dataclass(frozen=True)
class HelpFormatter:
left_padding: Dimension = (0, 0, 0, 2)
arg_format: ArgFormat = (
Markdown("{help}"),
Markdown("{choices}"),
Markdown("{default}", style="dim italic"),
)
Which I could imagine being massaged to, from something like arg_format=ArgFormat().help().choices().default(style='dim') or arg_format=ArgFormat.default(). idk what the relative importance of having a less manual builder is, it just seems unfortunate to have to manually construct e.g. Markdown("{help}"), if you decide you want different styling...
The main drawback is the forced newlines per your above rich discussion link https://github.com/Textualize/rich/discussions/3603. I can't tell how robust your conversion to text is, but it seems like he thought it wouldn't be robust.
Manual construction is fine to me. I suppose one can also modify the style after instantiation?
help_formatter = HelpFormatter(...)
help_formatter.arg_format[2].style = "dim italic"
The main drawback is the forced newlines per your above rich discussion link https://github.com/Textualize/rich/discussions/3603. I can't tell how robust your conversion to text is, but it seems like he thought it wouldn't be robust.
I think what Will meant is that things like headings, horizontal lines, bullet lists, etc., won't be translatable to styles indeed, but that doesn't really concerns us here since we're only expected to use bold, italic, links or code elements. WDYT? This could simply be documented as a limitation. Or both modes could be supported somehow, if you think users will use such Markdown constructs (horizontal rules, bullet lists, etc.): one mode where Markdown is left intact, and another where it's translated to styled text as best as it can.
I was only really imagining inline constructs, but you can also have multi-line constructs/styling in Text, so it wasn't obvious to me why that aspect would be relevant to translation issues. I just sort of assumed round-tripping would end up mangling the styling. but if it doesn't work like that, then i'd probably be down to try it.
help_formatter.arg_format[2].style = "dim italic"
that's kind of why I was thinking about typing the arg as something specific. as-is, it's an immutable structure you'd have to redefine. whereas the above locks the API (or at least the default) such that changing the impl or default at all would be a breaking change.
In all the other cases of this (like Default), a user can supply a more basic type, but everything is ultimately coerced into the normalized version.
I'll probably try writing some of it out and see how good/bad it starts looking.
Does this ^ do anything for you? I think by default it maybe yields the behavior you were looking for:
- retain markdown help back compat
- ability to use Text instead of markdown opt-in
- dimmed default by way of applying a
styleto the "default" component
it's somewhat jank of an impl. namely requiring to output the rendered markdown to ansi, stripping the trailing newline, then load that as Text so it can be composed with the other components. but at least basically it appears to work and pass all existing tests.
I still probably ought to exercise it a bit more before committing to the new behavior but it seems less impossible/crazy than i was thinking initially so it might be a decent solution.
Trying right now. Default strings are indeed dimmed, and italicized π IIUC I could override arg_format to use text or markdown, right? The default being:
arg_format: ArgFormat = (
Markdown("{help}"),
Text("{choices}", style="italic"),
Markdown("{default}", style="dim italic"),
)
Didn't try it but sounds good to me π