From 7e027a80da92bc3d4d9350e11d0317724b1b79bb Mon Sep 17 00:00:00 2001 From: Yasyf Mohamedali Date: Mon, 5 Oct 2026 02:38:35 -0700 Subject: [PATCH] =?UTF-8?q?hookd:=20=F0=9F=90=9B=20Resolve=20releases=20fo?= =?UTF-8?q?r=20the=20product=20Python=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh hosts running Python 3.12 retry the release probe for ten minutes because `uv pip compile` resolves against the host interpreter, while `capt-hook` requires Python 3.13 or later. Read the Python lower bound from the embedded `pyproject.toml` and pass it through `--python-version` so the probe checks the product’s target interpreter. The existing polling deadline, unpublished-version handling, and errors when `uv` is missing remain covered. This changes the installer probe; the API actor and dependency lock remain unchanged. Validation from the retained Linux source worker: - Five focused Go tests passed, including the older-host regression, polling, deadline, missing executable, and Python-floor parsing. - Removing `--python-version` makes the two resolution regressions fail within their finite deadlines. - A synthetic Python package index tested with actual `uv` 0.12.21 and 0.12.23 resolves the published fixture at Python 3.13 and rejects Python 3.12 and an unpublished version. The worker had no Python 3.12 interpreter, so that target was selected explicitly. Live Ubuntu/Namespace validation follows the released installer; CI grades the full source change. --- internal/hookd/toolrelease.go | 32 ++++++++-- internal/hookd/toolrelease_test.go | 94 +++++++++++++++++++++++++++--- pyproject.go | 6 ++ 3 files changed, 117 insertions(+), 15 deletions(-) create mode 100644 pyproject.go diff --git a/internal/hookd/toolrelease.go b/internal/hookd/toolrelease.go index f3224c61..c52c6684 100644 --- a/internal/hookd/toolrelease.go +++ b/internal/hookd/toolrelease.go @@ -8,6 +8,8 @@ import ( "os/exec" "strings" "time" + + captainhook "github.com/yasyf/captain-hook" ) const ( @@ -20,24 +22,42 @@ func awaitProductRelease() error { if _, err := installedPython(); err == nil { return nil } + python, err := pythonFloor(captainhook.Pyproject) + if err != nil { + return err + } ctx, cancel := context.WithTimeout(context.Background(), releaseWaitTimeout) defer cancel() - if err := awaitResolvable(ctx, "uv", productDist, Build); err != nil { + if err := awaitResolvable(ctx, "uv", productDist, Build, python); err != nil { return fmt.Errorf("captain package: wait for capt-hook %s to resolve: %w", Build, err) } return nil } -// awaitResolvable holds until uv resolves dist==version from the sources it -// installs from: PyPI lists a fresh upload minutes after publish succeeds, and -// until then uv tool install fails with "No solution found". -func awaitResolvable(ctx context.Context, uv, dist, version string) error { +func pythonFloor(pyproject string) (string, error) { + for line := range strings.Lines(pyproject) { + spec, ok := strings.CutPrefix(strings.TrimSpace(line), "requires-python = ") + if !ok { + continue + } + for clause := range strings.SplitSeq(strings.Trim(spec, `"`), ",") { + if floor, ok := strings.CutPrefix(strings.TrimSpace(clause), ">="); ok { + return floor, nil + } + } + return "", fmt.Errorf("captain package: requires-python %s names no lower bound", spec) + } + return "", errors.New("captain package: pyproject.toml declares no requires-python") +} + +func awaitResolvable(ctx context.Context, uv, dist, version, python string) error { poll := time.NewTicker(releasePollEvery) defer poll.Stop() reason := "no probe finished" for ctx.Err() == nil { cmd := exec.CommandContext( - ctx, uv, "pip", "compile", "--no-deps", "--refresh-package", dist, "--quiet", "--no-header", "-", + ctx, uv, "pip", "compile", "--no-deps", "--refresh-package", dist, "--python-version", python, + "--quiet", "--no-header", "-", ) cmd.Stdin = strings.NewReader(dist + "==" + version + "\n") var stderr bytes.Buffer diff --git a/internal/hookd/toolrelease_test.go b/internal/hookd/toolrelease_test.go index 143568fd..222a9402 100644 --- a/internal/hookd/toolrelease_test.go +++ b/internal/hookd/toolrelease_test.go @@ -8,9 +8,11 @@ import ( "strings" "testing" "time" + + captainhook "github.com/yasyf/captain-hook" ) -func fakeUV(t *testing.T, failures int) (uv, requests string) { +func fakeUV(t *testing.T, failures int, supported string) (uv, requests string) { t.Helper() dir := t.TempDir() requests = filepath.Join(dir, "requests") @@ -18,39 +20,90 @@ func fakeUV(t *testing.T, failures int) (uv, requests string) { script := fmt.Sprintf(`#!/bin/sh cat >> %[1]q echo "$@" >> %[1]q +case " $* " in +*" --python-version %[3]s "*) ;; +*" --python-version "*) + echo "No solution found when resolving dependencies: the requested Python version does not satisfy Python>=%[3]s" >&2 + exit 1 ;; +*) + echo "No solution found when resolving dependencies: the current Python version (3.12.3) does not satisfy Python>=%[3]s" >&2 + exit 1 ;; +esac if [ "$(grep -c '^pip compile' %[1]q)" -le %[2]d ]; then echo "No solution found when resolving dependencies" >&2 exit 1 fi -`, requests, failures) +`, requests, failures, supported) if err := os.WriteFile(uv, []byte(script), 0o700); err != nil { //nolint:gosec // a fake uv must be executable t.Fatal(err) } return uv, requests } +func productFloor(t *testing.T) string { + t.Helper() + floor, err := pythonFloor(captainhook.Pyproject) + if err != nil { + t.Fatal(err) + } + return floor +} + +func probe(version, python string) string { + return "capt-hook==" + version + "\npip compile --no-deps --refresh-package capt-hook --python-version " + python + + " --quiet --no-header -\n" +} + func TestAwaitResolvableHoldsUntilUVResolvesTheVersion(t *testing.T) { t.Parallel() - uv, requests := fakeUV(t, 1) - if err := awaitResolvable(t.Context(), uv, productDist, "12.88.0"); err != nil { + floor := productFloor(t) + uv, requests := fakeUV(t, 1, floor) + ctx, cancel := context.WithTimeout(t.Context(), 2*releasePollEvery) + defer cancel() + if err := awaitResolvable(ctx, uv, productDist, "12.88.0", floor); err != nil { t.Fatalf("awaitResolvable = %v, want nil once uv resolves 12.88.0", err) } got, err := os.ReadFile(requests) if err != nil { t.Fatal(err) } - probe := "capt-hook==12.88.0\npip compile --no-deps --refresh-package capt-hook --quiet --no-header -\n" - if string(got) != probe+probe { + if want := probe("12.88.0", floor); string(got) != want+want { t.Fatalf("uv saw %q, want two resolution probes", got) } } +func TestAwaitResolvableResolvesForTheProductPythonOnAnOlderHost(t *testing.T) { + t.Parallel() + floor := productFloor(t) + uv, requests := fakeUV(t, 0, floor) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + if err := awaitResolvable(ctx, uv, productDist, "12.88.9", floor); err != nil { + t.Fatalf("awaitResolvable = %v, want the first probe to resolve for Python %s", err, floor) + } + got, err := os.ReadFile(requests) + if err != nil { + t.Fatal(err) + } + if want := probe("12.88.9", floor); string(got) != want { + t.Fatalf("uv saw %q, want one probe for Python %s", got, floor) + } + ctx, cancel = context.WithTimeout(t.Context(), time.Second) + defer cancel() + err = awaitResolvable(ctx, uv, productDist, "12.88.9", "3.12") + if err == nil || !strings.Contains(err.Error(), "uv cannot resolve capt-hook==12.88.9: No solution found") || + !strings.Contains(err.Error(), "does not satisfy Python>="+floor) { + t.Fatalf("awaitResolvable for the host's Python = %v, want the unsatisfied requires-python named", err) + } +} + func TestAwaitResolvableGivesUpAtItsDeadlineNamingTheVersion(t *testing.T) { t.Parallel() - uv, _ := fakeUV(t, 9) + floor := productFloor(t) + uv, _ := fakeUV(t, 9, floor) ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) defer cancel() - err := awaitResolvable(ctx, uv, productDist, "12.88.0") + err := awaitResolvable(ctx, uv, productDist, "12.88.0", floor) if err == nil || !strings.Contains(err.Error(), "uv cannot resolve capt-hook==12.88.0: No solution found") { t.Fatalf("awaitResolvable = %v, want the unresolved version named", err) } @@ -61,8 +114,31 @@ func TestAwaitResolvableFailsAtOnceWithoutUV(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), time.Minute) defer cancel() start := time.Now() - err := awaitResolvable(ctx, filepath.Join(t.TempDir(), "uv"), productDist, "12.88.0") + err := awaitResolvable(ctx, filepath.Join(t.TempDir(), "uv"), productDist, "12.88.0", productFloor(t)) if err == nil || time.Since(start) > releasePollEvery { t.Fatalf("awaitResolvable without uv = %v after %v, want an immediate failure", err, time.Since(start)) } } + +func TestPythonFloorReadsTheRequiresPythonLowerBound(t *testing.T) { + t.Parallel() + for pyproject, want := range map[string]string{ + "[project]\nname = \"capt-hook\"\nrequires-python = \">=3.13\"\n": "3.13", + "[project]\nrequires-python = \">=3.13.2, <3.16\"\n": "3.13.2", + } { + if got, err := pythonFloor(pyproject); err != nil || got != want { + t.Errorf("pythonFloor(%q) = %q, %v, want %q", pyproject, got, err, want) + } + } + for pyproject, reason := range map[string]string{ + "[project]\nrequires-python = \"==3.13.*\"\n": "names no lower bound", + "[project]\n# requires-python = \">=3.13\"\nname = \"x\"\n": "declares no requires-python", + } { + if _, err := pythonFloor(pyproject); err == nil || !strings.Contains(err.Error(), reason) { + t.Errorf("pythonFloor(%q) = %v, want %q", pyproject, err, reason) + } + } + if floor := productFloor(t); !strings.HasPrefix(floor, "3.") { + t.Fatalf("the product's Python floor %q is not a Python 3 version", floor) + } +} diff --git a/pyproject.go b/pyproject.go new file mode 100644 index 00000000..569efd8c --- /dev/null +++ b/pyproject.go @@ -0,0 +1,6 @@ +package captainhook + +import _ "embed" + +//go:embed pyproject.toml +var Pyproject string