Skip to content

Commit f4a9b89

Browse files
jawwad-aliclaude
andauthored
fix(cli): render the literal [suffix] in --tag help and rejection message (#3800)
Both places a user learns the `specify self upgrade --tag` syntax silently drop the `[suffix]` token, because Rich parses the literal square brackets as a markup tag and discards them: rejected tag -> "Invalid --tag: expected vMAJOR.MINOR.PATCH" (constant is "Invalid --tag: expected vMAJOR.MINOR.PATCH[suffix]") --help -> "Pin the target version (vX.Y.Z). Without --tag, ..." So the CLI implies a bare vX.Y.Z is the ONLY accepted form, when v1.0.0-rc1, v0.8.0.dev0 and v0.8.0+build.42 are all valid -- and the shipped docs advertise the suffix in four places (docs/upgrade.md x3, README.md x2). Escape the rejection message at the PRINT site rather than baking `\[` into _INVALID_TAG_MESSAGE: the same constant is raised through typer.BadParameter, which Click renders without Rich, so it must stay plain text. Escape the literal bracket in the option help, which Typer renders through Rich. Same literal-bracket class as the existing precedents in workflows/_commands.py (`\[disabled]`, `\[<type>]`). Static CLI text only -- no validation semantics change and `_validate_tag` is untouched. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b7b0e96 commit f4a9b89

2 files changed

Lines changed: 33 additions & 2 deletions

File tree

src/specify_cli/_version.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
import typer
2929
from packaging.version import InvalidVersion, Version
30+
from rich.markup import escape as _escape_markup
3031

3132
from ._download_security import MAX_JSON_METADATA_BYTES, read_response_limited
3233
from ._console import console
@@ -1230,7 +1231,10 @@ def self_upgrade(
12301231
tag: str | None = typer.Option(
12311232
None,
12321233
"--tag",
1233-
help="Pin the target version (vX.Y.Z[suffix]). Without --tag, the "
1234+
# Typer renders help through Rich, so escape the literal bracket (\[)
1235+
# or `[suffix]` is parsed as a style tag and dropped -- `--help` then
1236+
# advertises only `(vX.Y.Z)`, contradicting docs/upgrade.md and README.
1237+
help="Pin the target version (vX.Y.Z\\[suffix]). Without --tag, the "
12341238
"latest stable release is resolved via GitHub Releases.",
12351239
),
12361240
) -> None:
@@ -1270,7 +1274,14 @@ def self_upgrade(
12701274
try:
12711275
tag = _validate_tag(tag)
12721276
except typer.BadParameter as exc:
1273-
console.print(str(exc), soft_wrap=True)
1277+
# Escape at the print site rather than baking `\[` into
1278+
# _INVALID_TAG_MESSAGE: the message is also raised through
1279+
# typer.BadParameter, which Click renders without Rich, so the
1280+
# constant must stay plain text. Unescaped, Rich parses the literal
1281+
# `[suffix]` as a style tag and drops it, leaving the user with
1282+
# "expected vMAJOR.MINOR.PATCH" -- implying a bare vX.Y.Z is the only
1283+
# accepted form when -rc1 / .dev0 / +build.42 are all valid.
1284+
console.print(_escape_markup(str(exc)), soft_wrap=True)
12741285
raise typer.Exit(1) from exc
12751286

12761287
plan, failure_reason = _build_upgrade_plan(target_tag_override=tag)

tests/test_self_upgrade_verification.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,26 @@ def test_invalid_tags_rejected(self, bad_tag, uv_tool_argv0, clean_environ):
472472
output = strip_ansi(result.output)
473473
assert "Invalid --tag" in output or "expected vMAJOR.MINOR.PATCH" in output
474474

475+
def test_rejection_message_keeps_the_suffix_token(
476+
self, uv_tool_argv0, clean_environ
477+
):
478+
"""Rich must not swallow the literal `[suffix]`.
479+
480+
Unescaped it is parsed as a style tag and dropped, so the user is told
481+
only "expected vMAJOR.MINOR.PATCH" -- implying a bare vX.Y.Z is the only
482+
accepted form, when -rc1 / .dev0 / +build.42 are all valid and are
483+
documented as such in docs/upgrade.md and README.md.
484+
"""
485+
result = runner.invoke(app, ["self", "upgrade", "--tag", "latest"])
486+
assert result.exit_code == 1
487+
assert "expected vMAJOR.MINOR.PATCH[suffix]" in strip_ansi(result.output)
488+
489+
def test_tag_option_help_keeps_the_suffix_token(self):
490+
"""Typer renders option help through Rich, so `--help` dropped it too."""
491+
result = runner.invoke(app, ["self", "upgrade", "--help"])
492+
assert result.exit_code == 0
493+
assert "[suffix]" in strip_ansi(result.output)
494+
475495

476496
class TestUnknownCurrent:
477497
"""'unknown' current version renders literally in notice and success message."""

0 commit comments

Comments
 (0)