Skip to content

Commit 05d00ea

Browse files
committed
fix: address fourth self-upgrade review round
- self_upgrade: label a pinned target older than the installed version as "Downgrading" rather than "Upgrading" so `--tag <older>` is not mistaken for a forward upgrade. - resolver: drop the unused `typing.Optional` import and annotate the `--tag` option as `str | None`, consistent with the rest of the module (verified Typer resolves it on the supported Python versions). - _is_github_credential_env_key: add `_PASSWORD` and `_CREDENTIALS` to the recognized credential suffixes and document that only these shapes are scrubbed (not blanket coverage). - tests: assert the precise exit code (1) for the re-raised transient OSError path; skip the InvalidMetadataError test on Pythons where the real exception is absent instead of fabricating it; update the pinned downgrade test to expect the "Downgrading" label.
1 parent b570792 commit 05d00ea

3 files changed

Lines changed: 46 additions & 23 deletions

File tree

src/specify_cli/_version.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
from dataclasses import dataclass
2525
from enum import Enum
2626
from pathlib import Path
27-
from typing import Optional
2827

2928
import typer
3029
from packaging.version import InvalidVersion, Version
@@ -244,7 +243,14 @@ class _DetectionSignals:
244243
resolved_method: _InstallMethod
245244

246245

247-
_GITHUB_CREDENTIAL_SUFFIXES = ("_TOKEN", "_SECRET", "_KEY", "_PAT")
246+
_GITHUB_CREDENTIAL_SUFFIXES = (
247+
"_TOKEN",
248+
"_SECRET",
249+
"_KEY",
250+
"_PAT",
251+
"_PASSWORD",
252+
"_CREDENTIALS",
253+
)
248254
_UNRESOLVED_ENV_VAR_RE = re.compile(r"\$\w+|\$\{\w+\}|%[^%]+%")
249255

250256

@@ -260,9 +266,11 @@ def _is_github_credential_env_key(key: str) -> bool:
260266
``GITHUB_REPOSITORY``) the installer subprocess does not consume.
261267
- Otherwise the key is scrubbed only when it contains an underscore-delimited
262268
``_GITHUB_`` segment *and* ends with a credential suffix
263-
(``_TOKEN``/``_SECRET``/``_KEY``/``_PAT``) — e.g. ``HOMEBREW_GITHUB_API_TOKEN``.
264-
Un-delimited variants such as a hypothetical ``GITHUBTOKEN`` are not matched
265-
by this branch; no real tool sets such a name.
269+
(``_TOKEN``/``_SECRET``/``_KEY``/``_PAT``/``_PASSWORD``/``_CREDENTIALS``) —
270+
e.g. ``HOMEBREW_GITHUB_API_TOKEN``. Un-delimited variants such as a
271+
hypothetical ``GITHUBTOKEN`` are not matched by this branch; no real tool
272+
sets such a name. Only these recognized shapes are scrubbed — this is not
273+
blanket coverage of every conceivable secret name.
266274
"""
267275
upper = key.upper()
268276
if upper.startswith(("GH_", "GITHUB_")):
@@ -1205,7 +1213,7 @@ def self_upgrade(
12051213
help="Print the preview (method, current, target, installer argv) and "
12061214
"exit 0 without launching the installer subprocess.",
12071215
),
1208-
tag: Optional[str] = typer.Option(
1216+
tag: str | None = typer.Option(
12091217
None,
12101218
"--tag",
12111219
help="Pin the target version (vX.Y.Z[suffix]). Without --tag, the "
@@ -1339,10 +1347,20 @@ def self_upgrade(
13391347
raise typer.Exit(0)
13401348

13411349
# One-line pre-execution notice so the user sees exactly what will run
1342-
# before the installer's own output starts streaming.
1350+
# before the installer's own output starts streaming. A pinned target older
1351+
# than the installed version is a downgrade — say so explicitly so
1352+
# `--tag <older>` does not masquerade as a forward upgrade.
1353+
installed_version = _parse_version_text(plan.current_version)
1354+
verb = (
1355+
"Downgrading"
1356+
if tag is not None
1357+
and installed_version is not None
1358+
and target_version < installed_version
1359+
else "Upgrading"
1360+
)
13431361
argv_str = _render_argv(plan.installer_argv) if plan.installer_argv else ""
13441362
console.print(
1345-
f"Upgrading specify-cli {plan.current_version}{plan.target_tag} "
1363+
f"{verb} specify-cli {plan.current_version}{plan.target_tag} "
13461364
f"via {_method_label(plan.method)}: {argv_str}",
13471365
soft_wrap=True,
13481366
)

tests/test_self_upgrade_detection.py

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from pathlib import Path
88
from unittest.mock import patch
99

10+
import pytest
11+
1012
import specify_cli
1113
from specify_cli import app
1214

@@ -499,7 +501,9 @@ def test_pinned_older_tag_still_runs_installer(
499501
assert result.exit_code == 0
500502
out = strip_ansi(result.output)
501503
assert "Already on latest release" not in out
502-
assert "Upgrading specify-cli 0.7.6 → v0.7.5 via uv tool:" in out
504+
# A pinned older tag is a downgrade and must be labelled as such.
505+
assert "Downgrading specify-cli 0.7.6 → v0.7.5 via uv tool:" in out
506+
assert "Upgrading specify-cli" not in out
503507
assert mock_run.call_count == 2
504508

505509
def test_pinned_rc_tag_uses_canonical_version_equality_for_noop(
@@ -729,20 +733,18 @@ def fake_run(argv, *args, **kwargs):
729733

730734

731735
class TestEditableInstallMetadata:
736+
@pytest.mark.skipif(
737+
not hasattr(importlib.metadata, "InvalidMetadataError"),
738+
reason=(
739+
"importlib.metadata.InvalidMetadataError does not exist on this "
740+
"Python; _editable_direct_url_path only catches it when present, so "
741+
"fabricating it would exercise a path that cannot fire in production"
742+
),
743+
)
732744
def test_editable_marker_false_when_metadata_is_invalid(self):
733-
invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None)
734-
if invalid_metadata_error is None:
735-
class _FakeInvalidMetadataError(Exception):
736-
pass
737-
738-
invalid_metadata_error = _FakeInvalidMetadataError
739-
740-
with patch.object(
741-
importlib.metadata,
742-
"InvalidMetadataError",
743-
invalid_metadata_error,
744-
create=True,
745-
), patch(
745+
invalid_metadata_error = importlib.metadata.InvalidMetadataError
746+
747+
with patch(
746748
"importlib.metadata.distribution",
747749
side_effect=invalid_metadata_error("bad metadata"),
748750
):

tests/test_self_upgrade_execution.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,10 @@ def test_transient_exec_oserror_is_not_treated_as_invalid_installer(
386386
), patch("specify_cli._version.subprocess.run", side_effect=transient_error):
387387
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
388388
result = runner.invoke(app, ["self", "upgrade"])
389-
assert result.exit_code != 3
389+
# Transient/unknown OSErrors are re-raised rather than mapped to the
390+
# invalid-installer exit 3, so the CLI surfaces them as an uncaught
391+
# error: exit code 1 with the original OSError preserved.
392+
assert result.exit_code == 1
390393
assert isinstance(result.exception, OSError)
391394

392395

0 commit comments

Comments
 (0)