Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/src/content/docs/reference/cli/marketplace.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/cli/outdated.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
29 changes: 22 additions & 7 deletions src/apm_cli/commands/marketplace/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
kevinbeier-enbw marked this conversation as resolved.
return results


Expand Down
81 changes: 73 additions & 8 deletions src/apm_cli/commands/outdated.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,63 @@
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:
"""Strip leading 'v' prefix from a version string."""
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.

Expand Down Expand Up @@ -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]
Expand All @@ -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,
Expand Down
85 changes: 82 additions & 3 deletions src/apm_cli/marketplace/tag_pattern.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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<version>...)`` 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
-------
Expand Down Expand Up @@ -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
9 changes: 9 additions & 0 deletions tests/integration/test_outdated_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions tests/unit/commands/test_marketplace_outdated.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
Loading