diff --git a/docs/src/content/docs/reference/cli/marketplace.md b/docs/src/content/docs/reference/cli/marketplace.md index b178537a6..bb6a8c280 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_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}_v{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 9588495ce..3479d0a79 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_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/commands/marketplace/__init__.py b/src/apm_cli/commands/marketplace/__init__.py index 77ef30b9c..09ddf8e17 100644 --- a/src/apm_cli/commands/marketplace/__init__.py +++ b/src/apm_cli/commands/marketplace/__init__.py @@ -1027,15 +1027,30 @@ 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, 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): + 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..2892fa742 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,51 @@ 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") else "{version}" + return None + + +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 + + 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) + 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 +237,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 +252,28 @@ 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, package_basename) + 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, 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 = [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..11c3a4467 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}_v{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}" @@ -49,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 ------- @@ -98,6 +120,63 @@ 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"$") + + +def infer_tag_pattern(tag: str, package_name: str = "") -> str | None: + """Return the first default pattern that matches *tag*, or ``None``. + + When *package_name* is set, patterns containing ``{name}`` only match + tags for that package (monorepo-safe). + """ + for pattern in DEFAULT_TAG_PATTERNS: + 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 + + +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, *, name: str | None = None) -> str | None: + """Extract the semver substring from *tag* using *pattern*.""" + 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.groupdict().get("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..52102c902 100644 --- a/tests/integration/test_outdated_coverage.py +++ b/tests/integration/test_outdated_coverage.py @@ -47,6 +47,15 @@ def test_non_semver_rejected(self): assert _is_tag_ref("v1") is False assert _is_tag_ref("v1.2") is False + 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.""" 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..247be46c0 100644 --- a/tests/unit/commands/test_marketplace_outdated.py +++ b/tests/unit/commands/test_marketplace_outdated.py @@ -94,6 +94,56 @@ def yml_cwd(tmp_path, monkeypatch): return tmp_path +# --------------------------------------------------------------------------- +# Tag-pattern inference +# --------------------------------------------------------------------------- + + +class TestExtractTagVersionsInference: + """``_extract_tag_versions`` infers ``{name}_v{version}`` when default fails.""" + + def test_infers_name_underscore_v_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_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"] + + # --------------------------------------------------------------------------- # Happy path # --------------------------------------------------------------------------- diff --git a/tests/unit/marketplace/test_tag_pattern.py b/tests/unit/marketplace/test_tag_pattern.py index c26d0b201..d3a8f6106 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 @@ -152,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") @@ -209,7 +221,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: @@ -219,3 +231,56 @@ 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_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 + + 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_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_underscore_v_version(self) -> None: + 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: + 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_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 3a099375e..56f143ebe 100644 --- a/tests/unit/test_outdated_phase3w5.py +++ b/tests/unit/test_outdated_phase3w5.py @@ -361,6 +361,36 @@ def test_tag_up_to_date(self): result = _check_one_dep(dep, downloader, False) assert result.status == "up-to-date" + 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_v1.0.1", + ) + 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 + + 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_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