From 00085a7de6a85b83e9c66d0effa660a99200a62b Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 19 Oct 2026 12:56:36 +0200 Subject: [PATCH] dep_freshness: gittag looks up tags in the dependency's own repo A `pkg @ git+https://github.com//@pkg-vX.Y.Z` pin outside kuhyx/utils was asked of utils, which has no such tag, so steam-backlog-enforcer's leisure-watch pin was undeterminable (exit 3). Foreign pins are now named `//:`; utils pins keep their bare names (allowlist labels and cache keys unchanged). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N --- dep_freshness/parsers/python.py | 6 +- dep_freshness/registries/gittag.py | 35 +++++++++- dep_freshness/tests/test_gittag_source.py | 84 +++++++++++++++++++++++ 3 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 dep_freshness/tests/test_gittag_source.py diff --git a/dep_freshness/parsers/python.py b/dep_freshness/parsers/python.py index 1849bcd..e45058c 100644 --- a/dep_freshness/parsers/python.py +++ b/dep_freshness/parsers/python.py @@ -16,6 +16,7 @@ import tomllib from dep_freshness._tables import GITTAG, PYPI, TOOLCHAIN from dep_freshness.models import Dep from dep_freshness.parsers._lines import index +from dep_freshness.registries import gittag from dep_freshness.versions import exact_pin _REQ = re.compile( @@ -35,7 +36,7 @@ _SKIP_PREFIXES = ("-", "#", "git+", "http://", "https://", ".", "/") _DIRECT = re.compile( r"^\s*(?P[A-Za-z0-9][A-Za-z0-9._-]*)" r"(?:\[[^\]]*\])?" - r"\s*@\s*git\+\S+?@(?P[A-Za-z0-9_.-]+-v[0-9][^\s#]*)" + r"\s*@\s*git\+(?P\S+?)@(?P[A-Za-z0-9_.-]+-v[0-9][^\s#]*)" ) @@ -46,8 +47,9 @@ def _direct_reference(requirement: str, path: Path, line: int) -> Dep | None: return None ref = match.group("ref") package, _, version = ref.rpartition("-v") + name = gittag.qualified_name(match.group("url"), package or match.group("name")) return Dep( - ecosystem=GITTAG, name=package or match.group("name"), constraint=ref, + ecosystem=GITTAG, name=name, constraint=ref, path=path, line=line or _ref_line(path, ref), pinned=version or None, ) diff --git a/dep_freshness/registries/gittag.py b/dep_freshness/registries/gittag.py index d1c01f7..192114d 100644 --- a/dep_freshness/registries/gittag.py +++ b/dep_freshness/registries/gittag.py @@ -1,22 +1,53 @@ -"""Shared libraries consumed by git tag out of `kuhyx/utils`. +"""Libraries consumed by git tag: `kuhyx/utils` by default, or their own repo. "Newest" here is the newest tag matching `-vX.Y.Z`, not a registry answer. The transport is `git ls-remote` (see `_git.py`), never the REST API. + +A dep pinned out of kuhyx/utils keeps its bare package name -- that is the +allowlist label and cache key every consumer already has. Any other repo is +named `//:`, so the resolver (which only ever +sees the name) can still find the remote: asking utils for leisure-watch's +tags answered "undeterminable" (exit 3) in steam-backlog-enforcer. """ from __future__ import annotations import re +from urllib.parse import urlsplit from dep_freshness._tables import UTILS_TAG_REMOTE from dep_freshness.registries._git import ls_remote from dep_freshness.versions import newest_stable _TAG = re.compile(r"refs/tags/(?P[A-Za-z0-9_.-]+)-v(?P[0-9][^\s]*)$") +_UTILS_SOURCE = urlsplit(UTILS_TAG_REMOTE).netloc + urlsplit(UTILS_TAG_REMOTE).path -def latest(package: str, remote: str = UTILS_TAG_REMOTE) -> str | None: +def _source(url: str) -> str | None: + """`host/owner/repo` behind a git URL, or None when it cannot be read.""" + parts = urlsplit(url.strip()) + path = parts.path.strip("/").removesuffix(".git") + if not parts.hostname or path.count("/") < 1: + return None + return f"{parts.hostname}/{path}" + + +def qualified_name(url: str, package: str) -> str: + """The dep name for `package` pinned out of `url` (see module docstring). + + An unreadable URL falls back to the bare name -- the pre-existing + behaviour -- rather than a guessed remote. + """ + source = _source(url) + if source is None or source.casefold() == _UTILS_SOURCE.casefold(): + return package + return f"{source}:{package}" + + +def latest(name: str) -> str | None: """Highest semver tag prefixed with `-v`, or None if there is none.""" + source, _, package = name.rpartition(":") + remote = f"https://{source}" if source else UTILS_TAG_REMOTE found = [] for line in ls_remote(remote, "--tags", "--refs"): match = _TAG.search(line) diff --git a/dep_freshness/tests/test_gittag_source.py b/dep_freshness/tests/test_gittag_source.py new file mode 100644 index 0000000..d0f972c --- /dev/null +++ b/dep_freshness/tests/test_gittag_source.py @@ -0,0 +1,84 @@ +"""Git-tag deps outside kuhyx/utils are looked up in THEIR repo, not utils. + +`leisure-watch @ git+https://github.com/kuhyx/leisure-watch@leisure-watch-v0.1.0` +was asked of kuhyx/utils, which has no `leisure-watch-v*` tag, so the answer +came back undeterminable (exit 3) in steam-backlog-enforcer. The shared libs +in utils must keep their bare names: those are the allowlist labels and the +cache keys every consumer already has. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from dep_freshness._tables import UTILS_TAG_REMOTE +from dep_freshness.parsers import python +from dep_freshness.registries import gittag + +LW_URL = "https://github.com/kuhyx/leisure-watch" +REQS = f"""\ +leisure-watch @ git+{LW_URL}@leisure-watch-v0.1.0 +earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.14.0#subdirectory=earned_time +gatelock @ git+https://github.com/kuhyx/utils.git@gatelock-v0.11.0#subdirectory=gatelock +""" + + +def _names(tmp_path: Path) -> dict[str, str]: + path = tmp_path / "requirements.txt" + path.write_text(REQS, encoding="utf-8") + return {d.constraint: d.name for d in python.parse_requirements(path)} + + +def test_a_foreign_repo_pin_is_qualified_by_its_source(tmp_path): + names = _names(tmp_path) + assert ( + names["leisure-watch-v0.1.0"] == "github.com/kuhyx/leisure-watch:leisure-watch" + ) + + +def test_utils_pins_keep_their_bare_names(tmp_path): + names = _names(tmp_path) + assert names["earned-time-v0.14.0"] == "earned-time" + assert names["gatelock-v0.11.0"] == "gatelock" + + +@pytest.mark.parametrize( + ("url", "expected"), + [ + ("https://github.com/kuhyx/utils", "pkg"), + ("https://GitHub.com/Kuhyx/Utils.git/", "pkg"), + ("ssh://git@github.com/kuhyx/utils.git", "pkg"), + ("https://gitlab.com/someone/thing.git", "gitlab.com/someone/thing:pkg"), + ("not a url", "pkg"), + ("https://github.com/lonely", "pkg"), + ], +) +def test_qualified_name(url, expected): + assert gittag.qualified_name(url, "pkg") == expected + + +@pytest.fixture +def remotes(monkeypatch): + seen: list[str] = [] + + def fake_ls_remote(remote, *_args): + seen.append(remote) + return [ + "aaa\trefs/tags/leisure-watch-v0.1.0", + "bbb\trefs/tags/leisure-watch-v0.1.1", + ] + + monkeypatch.setattr(gittag, "ls_remote", fake_ls_remote) + return seen + + +def test_a_qualified_name_asks_its_own_repo(remotes): + assert gittag.latest("github.com/kuhyx/leisure-watch:leisure-watch") == "0.1.1" + assert remotes == [LW_URL] + + +def test_a_bare_name_still_asks_utils(remotes): + assert gittag.latest("leisure-watch") == "0.1.1" + assert remotes == [UTILS_TAG_REMOTE]