From e139f9cef5d7c4e90f8f3403020579c6dbff224e Mon Sep 17 00:00:00 2001 From: Kevin Beier Date: Wed, 27 May 2026 13:26:18 +0200 Subject: [PATCH 1/3] feat(cli): enhance tag handling for marketplace commands --- .../content/docs/reference/cli/marketplace.md | 5 ++ .../content/docs/reference/cli/outdated.md | 2 +- src/apm_cli/commands/marketplace/__init__.py | 25 +++++-- src/apm_cli/commands/outdated.py | 73 +++++++++++++++++-- src/apm_cli/marketplace/tag_pattern.py | 60 +++++++++++++++ tests/integration/test_outdated_coverage.py | 5 ++ .../commands/test_marketplace_outdated.py | 29 ++++++++ tests/unit/marketplace/test_tag_pattern.py | 46 +++++++++++- tests/unit/test_outdated_phase3w5.py | 28 +++++++ 9 files changed, 256 insertions(+), 17 deletions(-) diff --git a/docs/src/content/docs/reference/cli/marketplace.md b/docs/src/content/docs/reference/cli/marketplace.md index e613d2373..b997aa86c 100644 --- a/docs/src/content/docs/reference/cli/marketplace.md +++ b/docs/src/content/docs/reference/cli/marketplace.md @@ -201,6 +201,11 @@ versions available. | `--offline` | Use cached refs only. | | `--include-prerelease` | Consider prerelease tags. | +When remote tags use a non-default layout (for example `my-pkg@1.0.1`), set +`tag_pattern: "{name}@{version}"` on the package entry or under `build:` in +`apm.yml`. If no tags match the configured pattern, `apm marketplace outdated` +tries common layouts (`v{version}`, `{name}@{version}`, etc.) automatically. + ### `apm marketplace publish` Push marketplace updates to one or more **consumer** repositories, diff --git a/docs/src/content/docs/reference/cli/outdated.md b/docs/src/content/docs/reference/cli/outdated.md index b021b0487..b60a90996 100644 --- a/docs/src/content/docs/reference/cli/outdated.md +++ b/docs/src/content/docs/reference/cli/outdated.md @@ -17,7 +17,7 @@ apm outdated [OPTIONS] `apm outdated` reads `apm.lock.yaml` and queries each remote to detect staleness: -- **Tag-pinned deps** (e.g. `v1.2.3`): semver compare against the latest available remote tag. +- **Tag-pinned deps** (e.g. `v1.2.3`, `1.2.3`, or patterned tags like `my-pkg@1.2.3`): semver compare against the latest matching remote tag. Patterned tags (`{name}@{version}`, `{name}-v{version}`, etc.) are detected automatically from the locked ref. - **Branch-pinned deps** (e.g. `main`): compare the locked commit SHA against the remote branch tip. - **Default-branch deps** (no ref): compare against `main`/`master` tip. - **Marketplace deps**: compare the installed ref against the marketplace entry's current `source.ref`. diff --git a/src/apm_cli/commands/marketplace/__init__.py b/src/apm_cli/commands/marketplace/__init__.py index 77ef30b9c..a16170547 100644 --- a/src/apm_cli/commands/marketplace/__init__.py +++ b/src/apm_cli/commands/marketplace/__init__.py @@ -1027,15 +1027,26 @@ def _load_current_versions(): def _extract_tag_versions(refs, entry, yml, include_prerelease): """Extract (SemVer, tag_name) pairs from remote refs for a package entry.""" from ...marketplace._shared import iter_semver_tags - from ...marketplace.tag_pattern import build_tag_regex + from ...marketplace.tag_pattern import ( + build_tag_regex, + infer_tag_pattern_from_refs, + ) + + def _collect(pattern: str) -> list: + tag_rx = build_tag_regex(pattern) + collected = [] + for sv, tag_name, _ in iter_semver_tags(refs, tag_rx): + if sv.is_prerelease and not (include_prerelease or entry.include_prerelease): + continue + collected.append((sv, tag_name)) + return collected pattern = entry.tag_pattern or yml.build.tag_pattern - tag_rx = build_tag_regex(pattern) - results = [] - for sv, tag_name, _ in iter_semver_tags(refs, tag_rx): - if sv.is_prerelease and not (include_prerelease or entry.include_prerelease): - continue - results.append((sv, tag_name)) + results = _collect(pattern) + if not results: + inferred = infer_tag_pattern_from_refs(refs, entry.name) + if inferred and inferred != pattern: + results = _collect(inferred) return results diff --git a/src/apm_cli/commands/outdated.py b/src/apm_cli/commands/outdated.py index b9505fb9c..09d516c5d 100644 --- a/src/apm_cli/commands/outdated.py +++ b/src/apm_cli/commands/outdated.py @@ -20,9 +20,11 @@ TAG_RE = re.compile(r"^v?\d+\.\d+\.\d+") -def _is_tag_ref(ref: str) -> bool: - """Return True when *ref* looks like a semver tag (v1.2.3 or 1.2.3).""" - return bool(TAG_RE.match(ref)) if ref else False +def _is_tag_ref(ref: str, package_name: str | None = None) -> bool: + """Return True when *ref* names a version tag (plain or patterned).""" + from ..marketplace.tag_pattern import is_version_tag_ref + + return is_version_tag_ref(ref, package_name) def _strip_v(ref: str) -> str: @@ -30,6 +32,47 @@ def _strip_v(ref: str) -> str: return ref[1:] if ref and ref.startswith("v") else (ref or "") +def _package_basename(dep) -> str: + """Return the display name used in ``{name}`` tag patterns.""" + if dep.marketplace_plugin_name: + return dep.marketplace_plugin_name + repo = dep.repo_url or "" + if not repo: + return "" + return repo.rstrip("/").split("/")[-1] + + +def _resolve_tag_pattern(current_ref: str, package_name: str) -> str | None: + """Return the tag pattern for *current_ref*, or ``None`` if not a version tag.""" + from ..marketplace.tag_pattern import infer_tag_pattern + + inferred = infer_tag_pattern(current_ref, package_name) + if inferred: + return inferred + if TAG_RE.match(current_ref or ""): + return "v{version}" if (current_ref or "").startswith(("v", "V")) else "{version}" + return None + + +def _semver_tag_candidates(tag_refs, pattern: str): + """Return ``(SemVer, tag_name)`` pairs matching *pattern*, highest first.""" + from ..marketplace.semver import SemVer, parse_semver + from ..marketplace.tag_pattern import build_tag_regex, parse_tag_version + + tag_rx = build_tag_regex(pattern) + candidates: list[tuple[SemVer, str]] = [] + for remote_ref in tag_refs: + match = tag_rx.match(remote_ref.name) + if not match: + continue + version = match.group("version") + parsed = parse_semver(version) + if parsed is not None: + candidates.append((parsed, remote_ref.name)) + candidates.sort(key=lambda pair: pair[0], reverse=True) + return candidates + + def _find_remote_tip(ref_name, remote_refs): """Find the tip SHA for a branch ref from remote refs. @@ -190,7 +233,9 @@ def _check_one_dep(dep, downloader, verbose, registry_ctx=None): package=package_name, current=current_ref or "(none)", latest="-", status="unknown" ) - is_tag = _is_tag_ref(current_ref) + package_basename = _package_basename(dep) + tag_pattern = _resolve_tag_pattern(current_ref, package_basename) + is_tag = tag_pattern is not None if is_tag: tag_refs = [r for r in remote_refs if r.ref_type == GitReferenceType.TAG] @@ -203,12 +248,24 @@ def _check_one_dep(dep, downloader, verbose, registry_ctx=None): source="git tags", ) - latest_tag = tag_refs[0].name - current_ver = _strip_v(current_ref) - latest_ver = _strip_v(latest_tag) + from ..marketplace.tag_pattern import parse_tag_version + + candidates = _semver_tag_candidates(tag_refs, tag_pattern) + if not candidates: + return OutdatedRow( + package=package_name, + current=current_ref, + latest="-", + status="unknown", + source="git tags", + ) + + _, latest_tag = candidates[0] + current_ver = parse_tag_version(current_ref, tag_pattern) or _strip_v(current_ref) + latest_ver = parse_tag_version(latest_tag, tag_pattern) or _strip_v(latest_tag) if is_newer_version(current_ver, latest_ver): - extra = [r.name for r in tag_refs[:10]] if verbose else [] + extra = [name for _, name in candidates[:10]] if verbose else [] return OutdatedRow( package=package_name, current=current_ref, diff --git a/src/apm_cli/marketplace/tag_pattern.py b/src/apm_cli/marketplace/tag_pattern.py index cb49cf867..627541dc0 100644 --- a/src/apm_cli/marketplace/tag_pattern.py +++ b/src/apm_cli/marketplace/tag_pattern.py @@ -18,10 +18,28 @@ import re __all__ = [ + "DEFAULT_TAG_PATTERNS", "build_tag_regex", + "infer_tag_pattern", + "infer_tag_pattern_from_refs", + "is_version_tag_ref", + "parse_tag_version", "render_tag", ] +# Common tag layouts tried when no explicit ``tag_pattern`` is configured. +DEFAULT_TAG_PATTERNS: tuple[str, ...] = ( + "v{version}", + "{version}", + "{name}@{version}", + "{name}-v{version}", +) + +_PLAIN_SEMVER_RE = re.compile( + r"^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" +) + # Placeholders we recognise. _PLACEHOLDER_VERSION = "{version}" _PLACEHOLDER_NAME = "{name}" @@ -101,3 +119,45 @@ def build_tag_regex(pattern: str) -> re.Pattern[str]: escaped = escaped.replace(re.escape(_sentinel_name), r"[^/]+") return re.compile(r"^" + escaped + r"$") + + +def infer_tag_pattern(tag: str, package_name: str = "") -> str | None: + """Return the first default pattern that matches *tag*, or ``None``. + + *package_name* is accepted for API symmetry with :func:`render_tag` but + is not required for matching because ``{name}`` is a wildcard. + """ + del package_name # reserved for callers that pass the package display name + for pattern in DEFAULT_TAG_PATTERNS: + if build_tag_regex(pattern).match(tag): + return pattern + return None + + +def infer_tag_pattern_from_refs(refs: list, package_name: str = "") -> str | None: + """Infer a tag pattern from the first semver-like tag in *refs*.""" + for remote_ref in refs: + name = getattr(remote_ref, "name", "") or "" + if name.startswith("refs/tags/"): + name = name[len("refs/tags/") :] + found = infer_tag_pattern(name, package_name) + if found: + return found + return None + + +def parse_tag_version(tag: str, pattern: str) -> str | None: + """Extract the semver substring from *tag* using *pattern*.""" + match = build_tag_regex(pattern).match(tag) + if match is None: + return None + return match.group("version") + + +def is_version_tag_ref(ref: str, package_name: str | None = None) -> bool: + """Return True when *ref* names a version tag (plain or patterned).""" + if not ref: + return False + if _PLAIN_SEMVER_RE.match(ref): + return True + return infer_tag_pattern(ref, package_name or "") is not None diff --git a/tests/integration/test_outdated_coverage.py b/tests/integration/test_outdated_coverage.py index e90d77869..24e3e64a1 100644 --- a/tests/integration/test_outdated_coverage.py +++ b/tests/integration/test_outdated_coverage.py @@ -47,6 +47,11 @@ def test_non_semver_rejected(self): assert _is_tag_ref("v1") is False assert _is_tag_ref("v1.2") is False + def test_name_at_version_pattern(self): + """Recognizes ``{name}@{version}`` style tags.""" + assert _is_tag_ref("api-governance@1.0.1") is True + assert _is_tag_ref("my-tool@2.0.0") is True + def test_empty_string(self): """Empty string returns False.""" assert _is_tag_ref("") is False diff --git a/tests/unit/commands/test_marketplace_outdated.py b/tests/unit/commands/test_marketplace_outdated.py index df1789bb1..26161adef 100644 --- a/tests/unit/commands/test_marketplace_outdated.py +++ b/tests/unit/commands/test_marketplace_outdated.py @@ -94,6 +94,35 @@ def yml_cwd(tmp_path, monkeypatch): return tmp_path +# --------------------------------------------------------------------------- +# Tag-pattern inference +# --------------------------------------------------------------------------- + + +class TestExtractTagVersionsInference: + """``_extract_tag_versions`` infers ``{name}@{version}`` when default fails.""" + + def test_infers_name_at_version_from_remote_tags(self): + from types import SimpleNamespace + + from apm_cli.commands.marketplace import _extract_tag_versions + + entry = SimpleNamespace( + name="api-governance", + tag_pattern=None, + include_prerelease=False, + ) + yml = SimpleNamespace(build=SimpleNamespace(tag_pattern="v{version}")) + refs = [ + RemoteRef(name="refs/tags/api-governance@1.0.1", sha=_SHA_A), + RemoteRef(name="refs/tags/api-governance@1.0.2", sha=_SHA_B), + ] + results = _extract_tag_versions(refs, entry, yml, include_prerelease=False) + tag_names = [tag for _sv, tag in results] + assert "api-governance@1.0.1" in tag_names + assert "api-governance@1.0.2" in tag_names + + # --------------------------------------------------------------------------- # Happy path # --------------------------------------------------------------------------- diff --git a/tests/unit/marketplace/test_tag_pattern.py b/tests/unit/marketplace/test_tag_pattern.py index c26d0b201..0e3cc8bb3 100644 --- a/tests/unit/marketplace/test_tag_pattern.py +++ b/tests/unit/marketplace/test_tag_pattern.py @@ -6,7 +6,14 @@ import pytest -from apm_cli.marketplace.tag_pattern import build_tag_regex, render_tag +from apm_cli.marketplace.tag_pattern import ( + build_tag_regex, + infer_tag_pattern, + infer_tag_pattern_from_refs, + is_version_tag_ref, + parse_tag_version, + render_tag, +) # --------------------------------------------------------------------------- # render_tag @@ -219,3 +226,40 @@ def test_roundtrip(self, pattern: str, name: str, version: str) -> None: assert m is not None, f"Pattern {pattern!r} did not match rendered tag {tag!r}" if "{version}" in pattern: assert m.group("version") == version + + +class TestInferTagPattern: + def test_name_at_version(self) -> None: + assert infer_tag_pattern("api-governance@1.0.1") == "{name}@{version}" + + def test_plain_v_prefix(self) -> None: + assert infer_tag_pattern("v1.2.3") == "v{version}" + + def test_plain_semver(self) -> None: + assert infer_tag_pattern("1.2.3") == "{version}" + + def test_branch_not_matched(self) -> None: + assert infer_tag_pattern("main") is None + + +class TestIsVersionTagRef: + def test_name_at_version_is_tag(self) -> None: + assert is_version_tag_ref("api-governance@1.0.1") is True + + def test_main_is_not_tag(self) -> None: + assert is_version_tag_ref("main") is False + + +class TestParseTagVersion: + def test_name_at_version(self) -> None: + assert parse_tag_version("api-governance@1.0.1", "{name}@{version}") == "1.0.1" + + +class TestInferTagPatternFromRefs: + def test_infers_from_remote_ref(self) -> None: + class _Ref: + def __init__(self, name: str) -> None: + self.name = name + + refs = [_Ref("refs/tags/api-governance@1.0.1")] + assert infer_tag_pattern_from_refs(refs, "api-governance") == "{name}@{version}" diff --git a/tests/unit/test_outdated_phase3w5.py b/tests/unit/test_outdated_phase3w5.py index 3a099375e..41f1f5a6f 100644 --- a/tests/unit/test_outdated_phase3w5.py +++ b/tests/unit/test_outdated_phase3w5.py @@ -361,6 +361,34 @@ def test_tag_up_to_date(self): result = _check_one_dep(dep, downloader, False) assert result.status == "up-to-date" + def test_name_at_version_tag_outdated(self): + from apm_cli.commands.outdated import _check_one_dep + + dep = _make_dep( + key="org/api-governance", + repo_url="org/api-governance", + resolved_ref="api-governance@1.0.1", + ) + ref_tags = [ + _make_remote_ref("api-governance@1.0.2", "tag", "sha2"), + _make_remote_ref("api-governance@1.0.1", "tag", "sha1"), + ] + downloader = MagicMock() + downloader.list_remote_refs.return_value = ref_tags + + with ( + patch("apm_cli.commands.outdated._check_marketplace_ref", return_value=None), + patch( + "apm_cli.utils.version_checker.is_newer_version", + return_value=True, + ), + ): + result = _check_one_dep(dep, downloader, False) + + assert result.status == "outdated" + assert result.latest == "api-governance@1.0.2" + assert result.source == "git tags" + def test_tag_no_tags_returns_unknown(self): from apm_cli.commands.outdated import _check_one_dep From 6f33eb0681eed7843ce0a19ab9cea9d3411f7e97 Mon Sep 17 00:00:00 2001 From: Kevin Beier Date: Wed, 27 May 2026 13:41:12 +0200 Subject: [PATCH 2/3] fix(cli): update tag pattern handling to use underscore format --- .../content/docs/reference/cli/marketplace.md | 6 ++--- .../content/docs/reference/cli/outdated.md | 2 +- src/apm_cli/marketplace/tag_pattern.py | 2 +- tests/integration/test_outdated_coverage.py | 12 ++++++---- .../commands/test_marketplace_outdated.py | 12 +++++----- tests/unit/marketplace/test_tag_pattern.py | 24 ++++++++++++------- tests/unit/test_outdated_phase3w5.py | 10 ++++---- 7 files changed, 39 insertions(+), 29 deletions(-) diff --git a/docs/src/content/docs/reference/cli/marketplace.md b/docs/src/content/docs/reference/cli/marketplace.md index b997aa86c..2e20d8b53 100644 --- a/docs/src/content/docs/reference/cli/marketplace.md +++ b/docs/src/content/docs/reference/cli/marketplace.md @@ -201,10 +201,10 @@ versions available. | `--offline` | Use cached refs only. | | `--include-prerelease` | Consider prerelease tags. | -When remote tags use a non-default layout (for example `my-pkg@1.0.1`), set -`tag_pattern: "{name}@{version}"` on the package entry or under `build:` in +When remote tags use a non-default layout (for example `my-pkg_v1.0.1`), set +`tag_pattern: "{name}_v{version}"` on the package entry or under `build:` in `apm.yml`. If no tags match the configured pattern, `apm marketplace outdated` -tries common layouts (`v{version}`, `{name}@{version}`, etc.) automatically. +tries common layouts (`v{version}`, `{name}_v{version}`, etc.) automatically. ### `apm marketplace publish` diff --git a/docs/src/content/docs/reference/cli/outdated.md b/docs/src/content/docs/reference/cli/outdated.md index b60a90996..4e04d9203 100644 --- a/docs/src/content/docs/reference/cli/outdated.md +++ b/docs/src/content/docs/reference/cli/outdated.md @@ -17,7 +17,7 @@ apm outdated [OPTIONS] `apm outdated` reads `apm.lock.yaml` and queries each remote to detect staleness: -- **Tag-pinned deps** (e.g. `v1.2.3`, `1.2.3`, or patterned tags like `my-pkg@1.2.3`): semver compare against the latest matching remote tag. Patterned tags (`{name}@{version}`, `{name}-v{version}`, etc.) are detected automatically from the locked ref. +- **Tag-pinned deps** (e.g. `v1.2.3`, `1.2.3`, or patterned tags like `my-pkg_v1.2.3`): semver compare against the latest matching remote tag. Patterned tags (`{name}_v{version}`, `{name}-v{version}`, etc.) are detected automatically from the locked ref. - **Branch-pinned deps** (e.g. `main`): compare the locked commit SHA against the remote branch tip. - **Default-branch deps** (no ref): compare against `main`/`master` tip. - **Marketplace deps**: compare the installed ref against the marketplace entry's current `source.ref`. diff --git a/src/apm_cli/marketplace/tag_pattern.py b/src/apm_cli/marketplace/tag_pattern.py index 627541dc0..5e5e757cd 100644 --- a/src/apm_cli/marketplace/tag_pattern.py +++ b/src/apm_cli/marketplace/tag_pattern.py @@ -31,7 +31,7 @@ DEFAULT_TAG_PATTERNS: tuple[str, ...] = ( "v{version}", "{version}", - "{name}@{version}", + "{name}_v{version}", "{name}-v{version}", ) diff --git a/tests/integration/test_outdated_coverage.py b/tests/integration/test_outdated_coverage.py index 24e3e64a1..52102c902 100644 --- a/tests/integration/test_outdated_coverage.py +++ b/tests/integration/test_outdated_coverage.py @@ -47,10 +47,14 @@ def test_non_semver_rejected(self): assert _is_tag_ref("v1") is False assert _is_tag_ref("v1.2") is False - def test_name_at_version_pattern(self): - """Recognizes ``{name}@{version}`` style tags.""" - assert _is_tag_ref("api-governance@1.0.1") is True - assert _is_tag_ref("my-tool@2.0.0") is True + def test_name_underscore_v_version_pattern(self): + """Recognizes ``{name}_v{version}`` style tags.""" + assert _is_tag_ref("api-governance_v1.0.1") is True + assert _is_tag_ref("my-tool_v2.0.0") is True + + def test_name_at_version_not_recognized(self): + """``@`` is marketplace install syntax, not inferred as a git tag.""" + assert _is_tag_ref("api-governance@1.0.1") is False def test_empty_string(self): """Empty string returns False.""" diff --git a/tests/unit/commands/test_marketplace_outdated.py b/tests/unit/commands/test_marketplace_outdated.py index 26161adef..e80d3d6c7 100644 --- a/tests/unit/commands/test_marketplace_outdated.py +++ b/tests/unit/commands/test_marketplace_outdated.py @@ -100,9 +100,9 @@ def yml_cwd(tmp_path, monkeypatch): class TestExtractTagVersionsInference: - """``_extract_tag_versions`` infers ``{name}@{version}`` when default fails.""" + """``_extract_tag_versions`` infers ``{name}_v{version}`` when default fails.""" - def test_infers_name_at_version_from_remote_tags(self): + def test_infers_name_underscore_v_version_from_remote_tags(self): from types import SimpleNamespace from apm_cli.commands.marketplace import _extract_tag_versions @@ -114,13 +114,13 @@ def test_infers_name_at_version_from_remote_tags(self): ) yml = SimpleNamespace(build=SimpleNamespace(tag_pattern="v{version}")) refs = [ - RemoteRef(name="refs/tags/api-governance@1.0.1", sha=_SHA_A), - RemoteRef(name="refs/tags/api-governance@1.0.2", sha=_SHA_B), + RemoteRef(name="refs/tags/api-governance_v1.0.1", sha=_SHA_A), + RemoteRef(name="refs/tags/api-governance_v1.0.2", sha=_SHA_B), ] results = _extract_tag_versions(refs, entry, yml, include_prerelease=False) tag_names = [tag for _sv, tag in results] - assert "api-governance@1.0.1" in tag_names - assert "api-governance@1.0.2" in tag_names + assert "api-governance_v1.0.1" in tag_names + assert "api-governance_v1.0.2" in tag_names # --------------------------------------------------------------------------- diff --git a/tests/unit/marketplace/test_tag_pattern.py b/tests/unit/marketplace/test_tag_pattern.py index 0e3cc8bb3..5252a157f 100644 --- a/tests/unit/marketplace/test_tag_pattern.py +++ b/tests/unit/marketplace/test_tag_pattern.py @@ -216,7 +216,7 @@ class TestRoundTrip: ("{version}", "pkg", "0.0.1"), ("{name}-v{version}", "my-tool", "2.0.0"), ("release-{version}", "x", "10.20.30"), - ("{name}@{version}", "tool", "1.0.0-beta.1"), + ("{name}_v{version}", "tool", "1.0.0-beta.1"), ], ) def test_roundtrip(self, pattern: str, name: str, version: str) -> None: @@ -229,8 +229,11 @@ def test_roundtrip(self, pattern: str, name: str, version: str) -> None: class TestInferTagPattern: - def test_name_at_version(self) -> None: - assert infer_tag_pattern("api-governance@1.0.1") == "{name}@{version}" + def test_name_underscore_v_version(self) -> None: + assert infer_tag_pattern("api-governance_v1.0.1") == "{name}_v{version}" + + def test_name_at_version_not_inferred(self) -> None: + assert infer_tag_pattern("api-governance@1.0.1") is None def test_plain_v_prefix(self) -> None: assert infer_tag_pattern("v1.2.3") == "v{version}" @@ -243,16 +246,19 @@ def test_branch_not_matched(self) -> None: class TestIsVersionTagRef: - def test_name_at_version_is_tag(self) -> None: - assert is_version_tag_ref("api-governance@1.0.1") is True + def test_name_underscore_v_version_is_tag(self) -> None: + assert is_version_tag_ref("api-governance_v1.0.1") is True + + def test_name_at_version_not_tag(self) -> None: + assert is_version_tag_ref("api-governance@1.0.1") is False def test_main_is_not_tag(self) -> None: assert is_version_tag_ref("main") is False class TestParseTagVersion: - def test_name_at_version(self) -> None: - assert parse_tag_version("api-governance@1.0.1", "{name}@{version}") == "1.0.1" + def test_name_underscore_v_version(self) -> None: + assert parse_tag_version("api-governance_v1.0.1", "{name}_v{version}") == "1.0.1" class TestInferTagPatternFromRefs: @@ -261,5 +267,5 @@ class _Ref: def __init__(self, name: str) -> None: self.name = name - refs = [_Ref("refs/tags/api-governance@1.0.1")] - assert infer_tag_pattern_from_refs(refs, "api-governance") == "{name}@{version}" + refs = [_Ref("refs/tags/api-governance_v1.0.1")] + assert infer_tag_pattern_from_refs(refs, "api-governance") == "{name}_v{version}" diff --git a/tests/unit/test_outdated_phase3w5.py b/tests/unit/test_outdated_phase3w5.py index 41f1f5a6f..3b859c0e7 100644 --- a/tests/unit/test_outdated_phase3w5.py +++ b/tests/unit/test_outdated_phase3w5.py @@ -361,17 +361,17 @@ def test_tag_up_to_date(self): result = _check_one_dep(dep, downloader, False) assert result.status == "up-to-date" - def test_name_at_version_tag_outdated(self): + def test_name_underscore_v_version_tag_outdated(self): from apm_cli.commands.outdated import _check_one_dep dep = _make_dep( key="org/api-governance", repo_url="org/api-governance", - resolved_ref="api-governance@1.0.1", + resolved_ref="api-governance_v1.0.1", ) ref_tags = [ - _make_remote_ref("api-governance@1.0.2", "tag", "sha2"), - _make_remote_ref("api-governance@1.0.1", "tag", "sha1"), + _make_remote_ref("api-governance_v1.0.2", "tag", "sha2"), + _make_remote_ref("api-governance_v1.0.1", "tag", "sha1"), ] downloader = MagicMock() downloader.list_remote_refs.return_value = ref_tags @@ -386,7 +386,7 @@ def test_name_at_version_tag_outdated(self): result = _check_one_dep(dep, downloader, False) assert result.status == "outdated" - assert result.latest == "api-governance@1.0.2" + assert result.latest == "api-governance_v1.0.2" assert result.source == "git tags" def test_tag_no_tags_returns_unknown(self): From 09b5aeb1934781e064a864b8ebd5f57f10298e4e Mon Sep 17 00:00:00 2001 From: Kevin Beier Date: Wed, 27 May 2026 13:56:37 +0200 Subject: [PATCH 3/3] fix(outdated): scope {name} tag patterns to package When a tag_pattern contains {name}, specialize matching to the target package name so monorepo tags for other packages don't skew outdated results. Also harden parse_tag_version for patterns without {version}. Co-authored-by: Cursor --- src/apm_cli/commands/marketplace/__init__.py | 6 ++- src/apm_cli/commands/outdated.py | 22 +++++++---- src/apm_cli/marketplace/tag_pattern.py | 39 ++++++++++++++----- .../commands/test_marketplace_outdated.py | 21 ++++++++++ tests/unit/marketplace/test_tag_pattern.py | 17 +++++++- tests/unit/test_outdated_phase3w5.py | 2 + 6 files changed, 88 insertions(+), 19 deletions(-) diff --git a/src/apm_cli/commands/marketplace/__init__.py b/src/apm_cli/commands/marketplace/__init__.py index a16170547..09ddf8e17 100644 --- a/src/apm_cli/commands/marketplace/__init__.py +++ b/src/apm_cli/commands/marketplace/__init__.py @@ -1033,7 +1033,11 @@ def _extract_tag_versions(refs, entry, yml, include_prerelease): ) def _collect(pattern: str) -> list: - tag_rx = build_tag_regex(pattern) + tag_rx = ( + build_tag_regex(pattern, name=entry.name) + if "{name}" in pattern + else build_tag_regex(pattern) + ) collected = [] for sv, tag_name, _ in iter_semver_tags(refs, tag_rx): if sv.is_prerelease and not (include_prerelease or entry.include_prerelease): diff --git a/src/apm_cli/commands/outdated.py b/src/apm_cli/commands/outdated.py index 09d516c5d..2892fa742 100644 --- a/src/apm_cli/commands/outdated.py +++ b/src/apm_cli/commands/outdated.py @@ -50,16 +50,20 @@ def _resolve_tag_pattern(current_ref: str, package_name: str) -> str | None: if inferred: return inferred if TAG_RE.match(current_ref or ""): - return "v{version}" if (current_ref or "").startswith(("v", "V")) else "{version}" + return "v{version}" if (current_ref or "").startswith("v") else "{version}" return None -def _semver_tag_candidates(tag_refs, pattern: str): +def _semver_tag_candidates(tag_refs, pattern: str, package_name: str = ""): """Return ``(SemVer, tag_name)`` pairs matching *pattern*, highest first.""" from ..marketplace.semver import SemVer, parse_semver - from ..marketplace.tag_pattern import build_tag_regex, parse_tag_version + from ..marketplace.tag_pattern import build_tag_regex - tag_rx = build_tag_regex(pattern) + tag_rx = ( + build_tag_regex(pattern, name=package_name) + if "{name}" in pattern and package_name + else build_tag_regex(pattern) + ) candidates: list[tuple[SemVer, str]] = [] for remote_ref in tag_refs: match = tag_rx.match(remote_ref.name) @@ -250,7 +254,7 @@ def _check_one_dep(dep, downloader, verbose, registry_ctx=None): from ..marketplace.tag_pattern import parse_tag_version - candidates = _semver_tag_candidates(tag_refs, tag_pattern) + candidates = _semver_tag_candidates(tag_refs, tag_pattern, package_basename) if not candidates: return OutdatedRow( package=package_name, @@ -261,8 +265,12 @@ def _check_one_dep(dep, downloader, verbose, registry_ctx=None): ) _, latest_tag = candidates[0] - current_ver = parse_tag_version(current_ref, tag_pattern) or _strip_v(current_ref) - latest_ver = parse_tag_version(latest_tag, tag_pattern) or _strip_v(latest_tag) + current_ver = ( + parse_tag_version(current_ref, tag_pattern, name=package_basename) or _strip_v(current_ref) + ) + latest_ver = ( + parse_tag_version(latest_tag, tag_pattern, name=package_basename) or _strip_v(latest_tag) + ) if is_newer_version(current_ver, latest_ver): extra = [name for _, name in candidates[:10]] if verbose else [] diff --git a/src/apm_cli/marketplace/tag_pattern.py b/src/apm_cli/marketplace/tag_pattern.py index 5e5e757cd..11c3a4467 100644 --- a/src/apm_cli/marketplace/tag_pattern.py +++ b/src/apm_cli/marketplace/tag_pattern.py @@ -67,18 +67,22 @@ def render_tag(pattern: str, *, name: str, version: str) -> str: return result -def build_tag_regex(pattern: str) -> re.Pattern[str]: +def build_tag_regex(pattern: str, *, name: str | None = None) -> re.Pattern[str]: """Return a compiled regex that captures ``{version}`` from a tag. Literal text in *pattern* is escaped so that special regex characters (e.g. dots, parens) are matched verbatim. ``{version}`` becomes a named capture group ``(?P...)`` matching a semver-like - string. ``{name}`` becomes a non-capturing wildcard ``[^/]+``. + string. ``{name}`` becomes a non-capturing wildcard ``[^/]+``, or + the literal *name* when provided (for monorepo per-package tags). Parameters ---------- pattern: Tag pattern string, e.g. ``"v{version}"``. + name: + When set and the pattern contains ``{name}``, match only this + package name instead of any ``[^/]+`` segment. Returns ------- @@ -116,7 +120,11 @@ def build_tag_regex(pattern: str) -> re.Pattern[str]: ) escaped = escaped.replace(re.escape(_sentinel_version), _VERSION_RX) - escaped = escaped.replace(re.escape(_sentinel_name), r"[^/]+") + if _PLACEHOLDER_NAME in pattern and name: + name_rx = re.escape(name) + else: + name_rx = r"[^/]+" + escaped = escaped.replace(re.escape(_sentinel_name), name_rx) return re.compile(r"^" + escaped + r"$") @@ -124,12 +132,16 @@ def build_tag_regex(pattern: str) -> re.Pattern[str]: def infer_tag_pattern(tag: str, package_name: str = "") -> str | None: """Return the first default pattern that matches *tag*, or ``None``. - *package_name* is accepted for API symmetry with :func:`render_tag` but - is not required for matching because ``{name}`` is a wildcard. + When *package_name* is set, patterns containing ``{name}`` only match + tags for that package (monorepo-safe). """ - del package_name # reserved for callers that pass the package display name for pattern in DEFAULT_TAG_PATTERNS: - if build_tag_regex(pattern).match(tag): + rx = ( + build_tag_regex(pattern, name=package_name) + if _PLACEHOLDER_NAME in pattern and package_name + else build_tag_regex(pattern) + ) + if rx.match(tag): return pattern return None @@ -146,12 +158,19 @@ def infer_tag_pattern_from_refs(refs: list, package_name: str = "") -> str | Non return None -def parse_tag_version(tag: str, pattern: str) -> str | None: +def parse_tag_version(tag: str, pattern: str, *, name: str | None = None) -> str | None: """Extract the semver substring from *tag* using *pattern*.""" - match = build_tag_regex(pattern).match(tag) + if _PLACEHOLDER_VERSION not in pattern: + return None + rx = ( + build_tag_regex(pattern, name=name) + if _PLACEHOLDER_NAME in pattern and name + else build_tag_regex(pattern) + ) + match = rx.match(tag) if match is None: return None - return match.group("version") + return match.groupdict().get("version") def is_version_tag_ref(ref: str, package_name: str | None = None) -> bool: diff --git a/tests/unit/commands/test_marketplace_outdated.py b/tests/unit/commands/test_marketplace_outdated.py index e80d3d6c7..247be46c0 100644 --- a/tests/unit/commands/test_marketplace_outdated.py +++ b/tests/unit/commands/test_marketplace_outdated.py @@ -116,11 +116,32 @@ def test_infers_name_underscore_v_version_from_remote_tags(self): refs = [ RemoteRef(name="refs/tags/api-governance_v1.0.1", sha=_SHA_A), RemoteRef(name="refs/tags/api-governance_v1.0.2", sha=_SHA_B), + RemoteRef(name="refs/tags/other-pkg_v9.9.9", sha=_SHA_C), ] results = _extract_tag_versions(refs, entry, yml, include_prerelease=False) tag_names = [tag for _sv, tag in results] assert "api-governance_v1.0.1" in tag_names assert "api-governance_v1.0.2" in tag_names + assert "other-pkg_v9.9.9" not in tag_names + + def test_collect_ignores_other_package_tags_in_monorepo(self): + from types import SimpleNamespace + + from apm_cli.commands.marketplace import _extract_tag_versions + + entry = SimpleNamespace( + name="apm1", + tag_pattern="{name}_v{version}", + include_prerelease=False, + ) + yml = SimpleNamespace(build=SimpleNamespace(tag_pattern="v{version}")) + refs = [ + RemoteRef(name="refs/tags/apm1_v1.0.0", sha=_SHA_A), + RemoteRef(name="refs/tags/apm2_v1.0.0", sha=_SHA_B), + ] + results = _extract_tag_versions(refs, entry, yml, include_prerelease=False) + tag_names = [tag for _sv, tag in results] + assert tag_names == ["apm1_v1.0.0"] # --------------------------------------------------------------------------- diff --git a/tests/unit/marketplace/test_tag_pattern.py b/tests/unit/marketplace/test_tag_pattern.py index 5252a157f..d3a8f6106 100644 --- a/tests/unit/marketplace/test_tag_pattern.py +++ b/tests/unit/marketplace/test_tag_pattern.py @@ -159,6 +159,11 @@ def test_name_wildcard_non_greedy(self) -> None: assert m is not None assert m.group("version") == "2.0.0" + def test_name_specialized_to_package(self) -> None: + rx = build_tag_regex("{name}_v{version}", name="apm1") + assert rx.match("apm1_v1.0.0") is not None + assert rx.match("apm2_v1.0.0") is None + def test_complex_pattern(self) -> None: rx = build_tag_regex("{name}@{version}") m = rx.match("tool@3.1.4") @@ -232,6 +237,10 @@ class TestInferTagPattern: def test_name_underscore_v_version(self) -> None: assert infer_tag_pattern("api-governance_v1.0.1") == "{name}_v{version}" + def test_name_underscore_v_scoped_to_package(self) -> None: + assert infer_tag_pattern("apm1_v1.0.1", "apm1") == "{name}_v{version}" + assert infer_tag_pattern("apm2_v1.0.1", "apm1") is None + def test_name_at_version_not_inferred(self) -> None: assert infer_tag_pattern("api-governance@1.0.1") is None @@ -258,7 +267,13 @@ def test_main_is_not_tag(self) -> None: class TestParseTagVersion: def test_name_underscore_v_version(self) -> None: - assert parse_tag_version("api-governance_v1.0.1", "{name}_v{version}") == "1.0.1" + assert ( + parse_tag_version("api-governance_v1.0.1", "{name}_v{version}", name="api-governance") + == "1.0.1" + ) + + def test_pattern_without_version_returns_none(self) -> None: + assert parse_tag_version("tool-latest", "{name}-latest") is None class TestInferTagPatternFromRefs: diff --git a/tests/unit/test_outdated_phase3w5.py b/tests/unit/test_outdated_phase3w5.py index 3b859c0e7..56f143ebe 100644 --- a/tests/unit/test_outdated_phase3w5.py +++ b/tests/unit/test_outdated_phase3w5.py @@ -372,6 +372,7 @@ def test_name_underscore_v_version_tag_outdated(self): ref_tags = [ _make_remote_ref("api-governance_v1.0.2", "tag", "sha2"), _make_remote_ref("api-governance_v1.0.1", "tag", "sha1"), + _make_remote_ref("other-pkg_v9.9.9", "tag", "sha9"), ] downloader = MagicMock() downloader.list_remote_refs.return_value = ref_tags @@ -388,6 +389,7 @@ def test_name_underscore_v_version_tag_outdated(self): assert result.status == "outdated" assert result.latest == "api-governance_v1.0.2" assert result.source == "git tags" + assert "other-pkg" not in (result.latest or "") def test_tag_no_tags_returns_unknown(self): from apm_cli.commands.outdated import _check_one_dep