From e26fcae212e685bc9ada238691dc77f081f7b8fb Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Sun, 13 Sep 2026 08:49:05 +0200 Subject: [PATCH] PTD-23.1.2: Prove Requires-Python coverage Require each portable Python binding artifact to overlap its declared interpreter claims and require target artifacts to cover every advertised minor series completely. Preserve exact-claim checks and fail closed on malformed, unsupported, or contradictory constraints. Add focused coverage for interval boundaries, wildcard and equality prefixes, exclusions, contradictions, and claim aggregation. --- ...table-python-requires-python-coverage.yaml | 2 + internal/providers/python/version.go | 556 +++++++++++++++++- internal/providers/python/version_test.go | 230 ++++++++ internal/toolcatalog/records_compose.go | 30 +- internal/toolcatalog/records_compose_test.go | 25 + 5 files changed, 800 insertions(+), 43 deletions(-) create mode 100644 .changes/unreleased/+portable-python-requires-python-coverage.yaml create mode 100644 internal/providers/python/version_test.go diff --git a/.changes/unreleased/+portable-python-requires-python-coverage.yaml b/.changes/unreleased/+portable-python-requires-python-coverage.yaml new file mode 100644 index 00000000..2293cd2c --- /dev/null +++ b/.changes/unreleased/+portable-python-requires-python-coverage.yaml @@ -0,0 +1,2 @@ +kind: Fixed +body: Require portable Python binding artifacts to cover each advertised Python minor series completely. diff --git a/internal/providers/python/version.go b/internal/providers/python/version.go index 80e8803f..2b2e4f61 100644 --- a/internal/providers/python/version.go +++ b/internal/providers/python/version.go @@ -29,6 +29,10 @@ func ValidateInterpreterVersionV1(value string) error { return portabletool.ValidatePythonInterpreterVersionV1(value) } +func parseSupportedPythonClaimV1(value string) (providerapi.SupportedPythonClaimV1, error) { + return providerapi.ParseSupportedPythonClaimV1(value) +} + func NormalizeSupportedPythonClaimsV1(values []string) ([]string, error) { return providerapi.NormalizeSupportedPythonClaimsV1(values) } @@ -72,7 +76,32 @@ func requirementAllowsVersion(requirement string, version string) (bool, bool) { if remainder == "" || strings.HasPrefix(remainder, "@") { return true, true } - return versionSpecifiersAllowVersion(remainder, version) + return packageVersionSpecifiersAllowVersion(remainder, version) +} + +// packageVersionSpecifiersAllowVersion preserves the arbitrary-equality +// comparison available to package requirements even when its raw version text +// is outside the canonical public-release subset used for interpreter proofs. +func packageVersionSpecifiersAllowVersion(specifiers string, version string) (bool, bool) { + remaining := make([]string, 0) + for _, raw := range strings.Split(specifiers, ",") { + specifier := strings.TrimSpace(raw) + operator, expectedText, ok := splitVersionSpecifier(specifier) + if !ok { + return false, false + } + if operator != "===" { + remaining = append(remaining, specifier) + continue + } + if version != expectedText { + return false, true + } + } + if len(remaining) == 0 { + return true, true + } + return versionSpecifiersAllowVersion(strings.Join(remaining, ","), version) } // InterpreterVersionSatisfies evaluates the normalized release specifiers @@ -94,68 +123,268 @@ func InterpreterVersionSatisfies(constraint string, version string) (bool, error } func versionSpecifiersAllowVersion(specifiers string, version string) (bool, bool) { - actual, ok := parseReleaseVersion(version) + constraint, ok := parsePythonReleaseConstraintV1(specifiers) if !ok { return false, false } + actual, valid := parseReleaseVersion(version) + if !valid { + return false, false + } + return constraint.allows(actual, version), true +} + +type pythonReleaseBoundV1 struct { + version []int + inclusive bool + set bool +} + +type pythonReleaseConstraintV1 struct { + lower pythonReleaseBoundV1 + upper pythonReleaseBoundV1 + equality []int + equalityPrefix bool + strict string + excludedExact [][]int + excludedPrefixes [][]int + unsatisfiable bool +} + +func parsePythonReleaseConstraintV1(specifiers string) (pythonReleaseConstraintV1, bool) { + constraint := pythonReleaseConstraintV1{lower: pythonReleaseBoundV1{version: []int{0}, inclusive: true, set: true}} + if strings.TrimSpace(specifiers) == "" { + return constraint, true + } for _, raw := range strings.Split(specifiers, ",") { specifier := strings.TrimSpace(raw) + if specifier == "" { + return pythonReleaseConstraintV1{}, false + } operator, expectedText, ok := splitVersionSpecifier(specifier) if !ok { - return false, false + return pythonReleaseConstraintV1{}, false } if operator == "===" { - if version != expectedText { - return false, true + if strings.ContainsAny(expectedText, "+-*") { + return pythonReleaseConstraintV1{}, false + } + expected, valid := parseReleaseVersion(expectedText) + if !valid { + return pythonReleaseConstraintV1{}, false + } + if constraint.strict != "" && constraint.strict != expectedText { + constraint.unsatisfiable = true } + if !constrainPythonEqualityV1(&constraint, expected, false) { + constraint.unsatisfiable = true + } + constraint.strict = expectedText continue } + // Interpreter evidence is a canonical public release. Local-version + // labels cannot be compared faithfully after projecting to that domain, + // so keep those specifiers outside the normalized release subset. + if strings.Contains(expectedText, "+") { + return pythonReleaseConstraintV1{}, false + } wildcard := strings.HasSuffix(expectedText, ".*") if wildcard { if operator != "==" && operator != "!=" { - return false, false + return pythonReleaseConstraintV1{}, false } expectedText = strings.TrimSuffix(expectedText, ".*") } expected, ok := parseReleaseVersion(expectedText) - if !ok { - return false, false + if !ok || len(expected) == 0 { + return pythonReleaseConstraintV1{}, false } - comparison := compareReleaseVersions(actual, expected) - matches := false switch operator { case "==": - if wildcard { - matches = releaseHasPrefix(actual, expected) - } else { - matches = comparison == 0 + if !constrainPythonEqualityV1(&constraint, expected, wildcard) { + constraint.unsatisfiable = true } case "!=": if wildcard { - matches = !releaseHasPrefix(actual, expected) + constraint.excludedPrefixes = append(constraint.excludedPrefixes, expected) } else { - matches = comparison != 0 + constraint.excludedExact = append(constraint.excludedExact, expected) } case ">=": - matches = comparison >= 0 - case "<=": - matches = comparison <= 0 + constrainPythonLowerV1(&constraint, expected, true) case ">": - matches = comparison > 0 + constrainPythonLowerV1(&constraint, expected, false) + case "<=": + constrainPythonUpperV1(&constraint, expected, true) case "<": - matches = comparison < 0 + constrainPythonUpperV1(&constraint, expected, false) case "~=": - prefix := expected - if len(prefix) > 1 { - prefix = prefix[:len(prefix)-1] + if len(expected) < 2 { + return pythonReleaseConstraintV1{}, false + } + constrainPythonLowerV1(&constraint, expected, true) + prefix := expected[:len(expected)-1] + if !constrainPythonPrefixV1(&constraint, prefix) { + return pythonReleaseConstraintV1{}, false } - matches = comparison >= 0 && releaseHasPrefix(actual, prefix) + default: + return pythonReleaseConstraintV1{}, false } - if !matches { - return false, true + } + constraint.unsatisfiable = constraint.unsatisfiable || pythonConstraintIntervalEmptyV1(constraint) + return constraint, true +} + +func constrainPythonEqualityV1(constraint *pythonReleaseConstraintV1, expected []int, prefix bool) bool { + if constraint.equality == nil { + constraint.equality = append([]int{}, expected...) + constraint.equalityPrefix = prefix + return true + } + + current := constraint.equality + switch { + case !constraint.equalityPrefix && !prefix: + return compareReleaseVersions(current, expected) == 0 + case !constraint.equalityPrefix && prefix: + return pythonExactReleaseMatchesPrefixV1(current, expected) + case constraint.equalityPrefix && !prefix: + if !pythonExactReleaseMatchesPrefixV1(expected, current) { + return false + } + constraint.equality = append([]int{}, expected...) + constraint.equalityPrefix = false + return true + case releaseHasLiteralPrefixV1(current, expected): + return true + case releaseHasLiteralPrefixV1(expected, current): + constraint.equality = append([]int{}, expected...) + return true + default: + return false + } +} + +func pythonExactReleaseMatchesPrefixV1(exact, prefix []int) bool { + for index := 3; index < len(exact); index++ { + if exact[index] != 0 { + return false } } - return true, true + canonical := []int{ + pythonReleaseComponentV1(exact, 0), + pythonReleaseComponentV1(exact, 1), + pythonReleaseComponentV1(exact, 2), + } + return releaseHasPrefix(canonical, prefix) +} + +func constrainPythonLowerV1(constraint *pythonReleaseConstraintV1, version []int, inclusive bool) { + if !constraint.lower.set || compareReleaseVersions(version, constraint.lower.version) > 0 { + constraint.lower = pythonReleaseBoundV1{version: append([]int{}, version...), inclusive: inclusive, set: true} + } else if compareReleaseVersions(version, constraint.lower.version) == 0 { + constraint.lower.inclusive = constraint.lower.inclusive && inclusive + } +} + +func constrainPythonUpperV1(constraint *pythonReleaseConstraintV1, version []int, inclusive bool) { + if !constraint.upper.set || compareReleaseVersions(version, constraint.upper.version) < 0 { + constraint.upper = pythonReleaseBoundV1{version: append([]int{}, version...), inclusive: inclusive, set: true} + } else if compareReleaseVersions(version, constraint.upper.version) == 0 { + constraint.upper.inclusive = constraint.upper.inclusive && inclusive + } +} + +func constrainPythonPrefixV1(constraint *pythonReleaseConstraintV1, prefix []int) bool { + next, ok := nextReleasePrefixV1(prefix) + if !ok { + return false + } + constrainPythonLowerV1(constraint, prefix, true) + constrainPythonUpperV1(constraint, next, false) + return true +} + +func nextReleasePrefixV1(prefix []int) ([]int, bool) { + if len(prefix) == 0 || prefix[len(prefix)-1] == int(^uint(0)>>1) { + return nil, false + } + next := append([]int{}, prefix...) + next[len(next)-1]++ + return next, true +} + +func pythonConstraintIntervalEmptyV1(constraint pythonReleaseConstraintV1) bool { + if constraint.equality != nil { + if constraint.equalityPrefix { + next, ok := nextReleasePrefixV1(constraint.equality) + if !ok { + return true + } + if constraint.lower.set && compareReleaseVersions(next, constraint.lower.version) <= 0 { + return true + } + if constraint.upper.set { + comparison := compareReleaseVersions(constraint.equality, constraint.upper.version) + if comparison > 0 || comparison == 0 && !constraint.upper.inclusive { + return true + } + } + } else { + if constraint.lower.set && (compareReleaseVersions(constraint.equality, constraint.lower.version) < 0 || compareReleaseVersions(constraint.equality, constraint.lower.version) == 0 && !constraint.lower.inclusive) { + return true + } + if constraint.upper.set && (compareReleaseVersions(constraint.equality, constraint.upper.version) > 0 || compareReleaseVersions(constraint.equality, constraint.upper.version) == 0 && !constraint.upper.inclusive) { + return true + } + } + } + if !constraint.upper.set { + return false + } + comparison := compareReleaseVersions(constraint.lower.version, constraint.upper.version) + return comparison > 0 || comparison == 0 && (!constraint.lower.inclusive || !constraint.upper.inclusive) +} + +func (constraint pythonReleaseConstraintV1) allows(actual []int, raw string) bool { + if constraint.unsatisfiable { + return false + } + if constraint.strict != "" && raw != constraint.strict { + return false + } + if constraint.equality != nil { + if constraint.equalityPrefix { + if !releaseHasPrefix(actual, constraint.equality) { + return false + } + } else if compareReleaseVersions(actual, constraint.equality) != 0 { + return false + } + } + if constraint.lower.set { + comparison := compareReleaseVersions(actual, constraint.lower.version) + if comparison < 0 || comparison == 0 && !constraint.lower.inclusive { + return false + } + } + if constraint.upper.set { + comparison := compareReleaseVersions(actual, constraint.upper.version) + if comparison > 0 || comparison == 0 && !constraint.upper.inclusive { + return false + } + } + for _, excluded := range constraint.excludedExact { + if compareReleaseVersions(actual, excluded) == 0 { + return false + } + } + for _, excluded := range constraint.excludedPrefixes { + if releaseHasPrefix(actual, excluded) { + return false + } + } + return true } func splitVersionSpecifier(value string) (string, string, bool) { @@ -222,13 +451,280 @@ func compareReleaseVersions(left []int, right []int) int { } func releaseHasPrefix(version []int, prefix []int) bool { + for index, expected := range prefix { + if pythonReleaseComponentV1(version, index) != expected { + return false + } + } + return true +} + +func releaseHasLiteralPrefixV1(version []int, prefix []int) bool { if len(prefix) > len(version) { return false } - for index := range prefix { - if version[index] != prefix[index] { + for index, expected := range prefix { + if version[index] != expected { return false } } return true } + +type pythonReleaseIntervalV1 struct { + lower pythonReleaseBoundV1 + upper pythonReleaseBoundV1 +} + +func pythonConstraintIntervalV1(constraint pythonReleaseConstraintV1) (pythonReleaseIntervalV1, bool) { + if constraint.unsatisfiable { + return pythonReleaseIntervalV1{}, false + } + interval := pythonReleaseIntervalV1{lower: constraint.lower, upper: constraint.upper} + if constraint.equality == nil { + return interval, !pythonIntervalEmptyV1(interval) + } + if constraint.equalityPrefix { + next, ok := nextReleasePrefixV1(constraint.equality) + if !ok { + return pythonReleaseIntervalV1{}, false + } + pythonIntervalConstrainLowerV1(&interval, constraint.equality, true) + pythonIntervalConstrainUpperV1(&interval, next, false) + } else { + pythonIntervalConstrainLowerV1(&interval, constraint.equality, true) + pythonIntervalConstrainUpperV1(&interval, constraint.equality, true) + } + return interval, !pythonIntervalEmptyV1(interval) +} + +func pythonIntervalConstrainLowerV1(interval *pythonReleaseIntervalV1, version []int, inclusive bool) { + if !interval.lower.set || compareReleaseVersions(version, interval.lower.version) > 0 { + interval.lower = pythonReleaseBoundV1{version: append([]int{}, version...), inclusive: inclusive, set: true} + } else if compareReleaseVersions(version, interval.lower.version) == 0 { + interval.lower.inclusive = interval.lower.inclusive && inclusive + } +} + +func pythonIntervalConstrainUpperV1(interval *pythonReleaseIntervalV1, version []int, inclusive bool) { + if !interval.upper.set || compareReleaseVersions(version, interval.upper.version) < 0 { + interval.upper = pythonReleaseBoundV1{version: append([]int{}, version...), inclusive: inclusive, set: true} + } else if compareReleaseVersions(version, interval.upper.version) == 0 { + interval.upper.inclusive = interval.upper.inclusive && inclusive + } +} + +func pythonIntervalEmptyV1(interval pythonReleaseIntervalV1) bool { + if !interval.upper.set { + return false + } + comparison := compareReleaseVersions(interval.lower.version, interval.upper.version) + return comparison > 0 || comparison == 0 && (!interval.lower.inclusive || !interval.upper.inclusive) +} + +// PythonRequiresPythonIntersectsClaimV1 answers the weaker, nonempty-overlap +// question used when validating an individual artifact. +func PythonRequiresPythonIntersectsClaimV1(requiresPython, claim string) (bool, error) { + parsed, ok := parsePythonReleaseConstraintV1(strings.TrimSpace(requiresPython)) + if !ok { + return false, fmt.Errorf("Requires-Python %q is outside the normalized release subset", requiresPython) + } + parsedClaim, err := parseSupportedPythonClaimV1(claim) + if err != nil { + return false, err + } + if parsedClaim.Exact { + return parsed.allows([]int{parsedClaim.Major, parsedClaim.Minor, parsedClaim.Patch}, parsedClaim.String()), nil + } + if _, ok := nextReleasePrefixV1([]int{parsedClaim.Major, parsedClaim.Minor}); !ok { + return false, fmt.Errorf("Python interpreter claim %q has no representable minor successor", claim) + } + return pythonConstraintHasSeriesReleaseV1(parsed, parsedClaim.Major, parsedClaim.Minor), nil +} + +func pythonConstraintHasSeriesReleaseV1(constraint pythonReleaseConstraintV1, major, minor int) bool { + if constraint.unsatisfiable { + return false + } + if constraint.strict != "" { + patch, ok := pythonCanonicalInterpreterPatchV1(constraint.equality, major, minor) + if !ok { + return false + } + candidate, raw := pythonCanonicalInterpreterReleaseV1(major, minor, patch) + return raw == constraint.strict && constraint.allows(candidate, raw) + } + + patch, ok := pythonMinimumSeriesPatchV1(constraint.lower, major, minor) + if !ok { + return false + } + if constraint.equality != nil { + if constraint.equalityPrefix { + switch len(constraint.equality) { + case 1: + if constraint.equality[0] != major { + return false + } + case 2: + if constraint.equality[0] != major || constraint.equality[1] != minor { + return false + } + default: + equalPatch, valid := pythonCanonicalInterpreterPatchV1(constraint.equality, major, minor) + if !valid || equalPatch < patch { + return false + } + patch = equalPatch + } + } else { + equalPatch, valid := pythonCanonicalInterpreterPatchV1(constraint.equality, major, minor) + if !valid || equalPatch < patch { + return false + } + patch = equalPatch + } + } + + excluded, wholeSeries := pythonExcludedSeriesPatchesV1(constraint, major, minor) + if wholeSeries { + return false + } + for remaining := len(excluded); remaining >= 0; remaining-- { + if _, blocked := excluded[patch]; !blocked { + candidate, raw := pythonCanonicalInterpreterReleaseV1(major, minor, patch) + return constraint.allows(candidate, raw) + } + if patch == int(^uint(0)>>1) { + return false + } + patch++ + } + return false +} + +func pythonMinimumSeriesPatchV1(lower pythonReleaseBoundV1, major, minor int) (int, bool) { + if !lower.set { + return 0, true + } + start := []int{major, minor, 0} + comparison := compareReleaseVersions(start, lower.version) + if comparison > 0 { + return 0, true + } + if comparison == 0 { + if lower.inclusive { + return 0, true + } + return 1, true + } + if pythonReleaseComponentV1(lower.version, 0) != major || pythonReleaseComponentV1(lower.version, 1) != minor { + return 0, false + } + patch := pythonReleaseComponentV1(lower.version, 2) + candidate := []int{major, minor, patch} + comparison = compareReleaseVersions(candidate, lower.version) + if comparison > 0 || comparison == 0 && lower.inclusive { + return patch, true + } + if patch == int(^uint(0)>>1) { + return 0, false + } + return patch + 1, true +} + +func pythonExcludedSeriesPatchesV1(constraint pythonReleaseConstraintV1, major, minor int) (map[int]struct{}, bool) { + excluded := make(map[int]struct{}, len(constraint.excludedExact)+len(constraint.excludedPrefixes)) + for _, release := range constraint.excludedExact { + if patch, ok := pythonCanonicalInterpreterPatchV1(release, major, minor); ok { + excluded[patch] = struct{}{} + } + } + for _, prefix := range constraint.excludedPrefixes { + switch len(prefix) { + case 1: + if prefix[0] == major { + return nil, true + } + case 2: + if prefix[0] == major && prefix[1] == minor { + return nil, true + } + default: + if patch, ok := pythonCanonicalInterpreterPatchV1(prefix, major, minor); ok { + excluded[patch] = struct{}{} + } + } + } + return excluded, false +} + +func pythonCanonicalInterpreterPatchV1(release []int, major, minor int) (int, bool) { + if pythonReleaseComponentV1(release, 0) != major || pythonReleaseComponentV1(release, 1) != minor { + return 0, false + } + for index := 3; index < len(release); index++ { + if release[index] != 0 { + return 0, false + } + } + return pythonReleaseComponentV1(release, 2), true +} + +func pythonReleaseComponentV1(release []int, index int) int { + if index < len(release) { + return release[index] + } + return 0 +} + +func pythonCanonicalInterpreterReleaseV1(major, minor, patch int) ([]int, string) { + return []int{major, minor, patch}, fmt.Sprintf("%d.%d.%d", major, minor, patch) +} + +// PythonRequiresPythonCoversClaimV1 proves that the canonical constraint +// admits every release in a series, or admits the exact release of a point +// claim. Series proofs are interval based and never enumerate patch numbers. +func PythonRequiresPythonCoversClaimV1(requiresPython, claim string) (bool, error) { + parsed, ok := parsePythonReleaseConstraintV1(strings.TrimSpace(requiresPython)) + if !ok { + return false, fmt.Errorf("Requires-Python %q is outside the normalized release subset", requiresPython) + } + parsedClaim, err := parseSupportedPythonClaimV1(claim) + if err != nil { + return false, err + } + if parsedClaim.Exact { + return parsed.allows([]int{parsedClaim.Major, parsedClaim.Minor, parsedClaim.Patch}, parsedClaim.String()), nil + } + if parsed.strict != "" || parsed.equality != nil && !parsed.equalityPrefix { + return false, nil + } + minorSuccessor, ok := nextReleasePrefixV1([]int{parsedClaim.Major, parsedClaim.Minor}) + if !ok { + return false, fmt.Errorf("Python interpreter claim %q has no representable minor successor", claim) + } + claimed := pythonReleaseIntervalV1{ + lower: pythonReleaseBoundV1{version: []int{parsedClaim.Major, parsedClaim.Minor}, inclusive: true, set: true}, + upper: pythonReleaseBoundV1{version: minorSuccessor, inclusive: false, set: true}, + } + allowed, ok := pythonConstraintIntervalV1(parsed) + if !ok { + return false, nil + } + if compareReleaseVersions(allowed.lower.version, claimed.lower.version) > 0 || + compareReleaseVersions(allowed.lower.version, claimed.lower.version) == 0 && !allowed.lower.inclusive { + return false, nil + } + if allowed.upper.set { + comparison := compareReleaseVersions(allowed.upper.version, claimed.upper.version) + if comparison < 0 || comparison == 0 && claimed.upper.inclusive && !allowed.upper.inclusive { + return false, nil + } + } + excluded, wholeSeries := pythonExcludedSeriesPatchesV1(parsed, parsedClaim.Major, parsedClaim.Minor) + if wholeSeries || len(excluded) > 0 { + return false, nil + } + return true, nil +} diff --git a/internal/providers/python/version_test.go b/internal/providers/python/version_test.go new file mode 100644 index 00000000..627fc509 --- /dev/null +++ b/internal/providers/python/version_test.go @@ -0,0 +1,230 @@ +package python + +import ( + "strconv" + "testing" + + providerapi "github.com/omry/reploy/internal/providers" +) + +func TestPythonRequiresPythonClaimCoverageRejectsMinorSuccessorOverflowV1(t *testing.T) { + claim := "3." + strconv.Itoa(int(^uint(0)>>1)) + if _, err := PythonRequiresPythonCoversClaimV1(">=3", claim); err == nil { + t.Fatalf("coverage accepted unrepresentable minor successor %q", claim) + } + if _, err := PythonRequiresPythonIntersectsClaimV1(">=3", claim); err == nil { + t.Fatalf("intersection accepted unrepresentable minor successor %q", claim) + } +} + +func TestPythonRequiresPythonClaimCoverageV1(t *testing.T) { + cases := []struct { + requires string + claim string + want bool + }{ + {">=3.14,<3.15", "3.14", true}, + {"==3.14.*", "3.14", true}, + {"==3.*", "3.14", true}, + {"~=3.14", "3.14", true}, + {"~=3.14.0", "3.14", true}, + {"==3.14.*,<3.15", "3.14", true}, + {">=3.14,<3.14.1", "3.14", false}, + {"~=3.14.1", "3.14", false}, + {"==3.13.*", "3.14", false}, + {"==3.15.*", "3.14", false}, + {"~=3.13.0", "3.14", false}, + {"~=3.15.0", "3.14", false}, + {"==3.14.*,>3.14", "3.14", false}, + {"==3.14.*,<=3.14", "3.14", false}, + {"==3.14.*,<3.14.1", "3.14", false}, + {">=3.14,!=3.14.1", "3.14", false}, + {">=3.14,<3.15,!=3.14.1.2", "3.14", true}, + {">=3.14,<3.15,!=3.14.1.2.*", "3.14", true}, + {">=3.14,<3.15,!=3.14.1.0.*", "3.14", false}, + {">=3.14,<3.15,!=3.14.1.0", "3.14", false}, + {">=3.14,<3.15,!=3.14.1.*", "3.14", false}, + {">=3.14,<3.15", "3.14.1", true}, + {">=3.14,<3.15", "3.15.1", false}, + {"==3.14.1.0.*", "3.14.1", true}, + {"!=3.14.1.0.*", "3.14.1", false}, + } + for _, test := range cases { + got, err := PythonRequiresPythonCoversClaimV1(test.requires, test.claim) + if err != nil { + t.Fatalf("coverage %q/%q: %v", test.requires, test.claim, err) + } + if got != test.want { + t.Errorf("coverage %q/%q = %v, want %v", test.requires, test.claim, got, test.want) + } + } +} + +func TestPythonRequiresPythonClaimIntersectionUsesCanonicalRuntimeReleasesV1(t *testing.T) { + cases := []struct { + requires string + claim string + want bool + }{ + {">3.14.0,<3.14.1", "3.14", false}, + {">=3.14,<3.14.1,!=3.14", "3.14", false}, + {"===3.14", "3.14", false}, + {"===3.14.0", "3.14", true}, + {">=3.14,<3.15,!=3.14.1.2", "3.14", true}, + {">=3.14,<3.15,!=3.14.1.2.*", "3.14", true}, + {"==3.14.1.0.*", "3.14", true}, + {">=3.14,<3.14.1", "3.14", true}, + } + for _, test := range cases { + got, err := PythonRequiresPythonIntersectsClaimV1(test.requires, test.claim) + if err != nil { + t.Fatalf("intersection %q/%q: %v", test.requires, test.claim, err) + } + if got != test.want { + t.Errorf("intersection %q/%q = %v, want %v", test.requires, test.claim, got, test.want) + } + } +} + +func TestCompatiblePythonReleaseEqualitiesRemainAConjunctionV1(t *testing.T) { + for _, constraint := range []string{ + "==3.14,==3.14.*", + "==3.14.*,==3.14", + "==3.14.*,<=3.14", + "==3.14.*,==3.14.0.*", + } { + matches, err := InterpreterVersionSatisfies(constraint, "3.14.0") + if err != nil { + t.Errorf("constraint %q: %v", constraint, err) + continue + } + if !matches { + t.Errorf("constraint %q rejected 3.14.0", constraint) + } + } + for _, constraint := range []string{ + "==3.14.1,==3.14.1.0.*", + "==3.14.1.0.*,==3.14.1", + } { + if matches, supported := versionSpecifiersAllowVersion(constraint, "3.14.1"); !supported || !matches { + t.Errorf("constraint %q rejected its zero-padded three-component runtime", constraint) + } + } + if matches, supported := versionSpecifiersAllowVersion("==3.14.*,==3.14.0.*", "3.14.1"); !supported || matches { + t.Error("conjoined wildcard prefixes admitted a release outside the narrower literal prefix") + } +} + +func TestPythonReleaseWildcardPrefixesUseZeroPaddingV1(t *testing.T) { + for _, test := range []struct { + constraint string + version string + want bool + }{ + {"==3.14.1.0.*", "3.14.1", true}, + {"==3.14.1.0.*", "3.14.2", false}, + {"!=3.14.1.0.*", "3.14.1", false}, + {"!=3.14.1.0.*", "3.14.2", true}, + {"==3.14.1.2.*", "3.14.1", false}, + {"!=3.14.1.2.*", "3.14.1", true}, + } { + got, err := InterpreterVersionSatisfies(test.constraint, test.version) + if err != nil { + t.Fatalf("constraint %q/version %q: %v", test.constraint, test.version, err) + } + if got != test.want { + t.Errorf("constraint %q/version %q = %v, want %v", test.constraint, test.version, got, test.want) + } + } +} + +func TestPythonReleaseConstraintsRejectLocalVersionSpecifiersV1(t *testing.T) { + for _, constraint := range []string{"==3.14.1+vendor", "!=3.14.1+vendor", "===3.14.1+vendor"} { + if matches, supported := versionSpecifiersAllowVersion(constraint, "3.14.1"); supported || matches { + t.Errorf("constraint %q was admitted to the normalized runtime subset", constraint) + } + if _, err := InterpreterVersionSatisfies(constraint, "3.14.1"); err == nil { + t.Errorf("runtime constraint %q did not fail closed", constraint) + } + if _, err := PythonRequiresPythonCoversClaimV1(constraint, "3.14.1"); err == nil { + t.Errorf("coverage constraint %q did not fail closed", constraint) + } + if _, err := PythonRequiresPythonIntersectsClaimV1(constraint, "3.14.1"); err == nil { + t.Errorf("intersection constraint %q did not fail closed", constraint) + } + } +} + +func TestStrictPythonReleaseConstraintRemainsAConjunctionV1(t *testing.T) { + for _, specifier := range []string{"===3.14.1,<3.14", ">3.14.1,===3.14.1", "===not-a-release"} { + if matches, valid := versionSpecifiersAllowVersion(specifier, "3.14.1"); valid && matches { + t.Errorf("constraint %q admitted 3.14.1", specifier) + } + } +} + +func TestStrictPythonPackageRequirementsPreserveRawEqualityV1(t *testing.T) { + for _, test := range []struct { + version string + want bool + }{ + {version: "1.0+vendor", want: true}, + {version: "1.0+other", want: false}, + {version: "1.0", want: false}, + } { + got, checked := requirementAllowsVersion("demo===1.0+vendor", test.version) + if !checked || got != test.want { + t.Errorf("requirementAllowsVersion(strict local, %q) = (%v, %v), want (%v, true)", test.version, got, checked, test.want) + } + } + + packageRequest, err := CanonicalPackageRequestV1("demo===1.0+vendor") + if err != nil { + t.Fatal(err) + } + request := PythonProviderRequestV1{ + Component: "application", + Requirements: []providerapi.CanonicalPackageRequest{packageRequest}, + } + if err := validateCanonicalRequestedDistributions(request, map[string]inspectedWheel{ + "demo": {Distribution: "demo", Version: "1.0+other"}, + }); err == nil { + t.Fatal("prepared-bundle validation accepted a wheel that violates strict local-version equality") + } +} + +func TestContradictoryPythonReleaseConstraintsRemainCheckedV1(t *testing.T) { + for _, test := range []struct { + requirement string + specifiers string + }{ + {requirement: "demo>=2,<1", specifiers: ">=2,<1"}, + {requirement: "demo==1,==2", specifiers: "==1,==2"}, + } { + requirement := test.requirement + if satisfied, checked := requirementAllowsVersion(requirement, "1.5"); !checked || satisfied { + t.Errorf("requirementAllowsVersion(%q, %q) = (%v, %v), want (false, true)", requirement, "1.5", satisfied, checked) + } + if covered, err := PythonRequiresPythonCoversClaimV1(test.specifiers, "1.5"); err != nil || covered { + t.Errorf("PythonRequiresPythonCoversClaimV1(%q, %q) = (%v, %v), want (false, nil)", test.specifiers, "1.5", covered, err) + } + if intersects, err := PythonRequiresPythonIntersectsClaimV1(test.specifiers, "1.5"); err != nil || intersects { + t.Errorf("PythonRequiresPythonIntersectsClaimV1(%q, %q) = (%v, %v), want (false, nil)", test.specifiers, "1.5", intersects, err) + } + + packageRequest, err := CanonicalPackageRequestV1(requirement) + if err != nil { + t.Fatalf("canonicalize %q: %v", requirement, err) + } + request := PythonProviderRequestV1{ + Component: "application", + Requirements: []providerapi.CanonicalPackageRequest{packageRequest}, + } + artifacts := map[string]inspectedWheel{ + "demo": {Distribution: "demo", Version: "1.5"}, + } + if err := validateCanonicalRequestedDistributions(request, artifacts); err == nil { + t.Errorf("prepared-bundle validation accepted contradictory requirement %q", requirement) + } + } +} diff --git a/internal/toolcatalog/records_compose.go b/internal/toolcatalog/records_compose.go index 4f9ce4f9..e7092fa3 100644 --- a/internal/toolcatalog/records_compose.go +++ b/internal/toolcatalog/records_compose.go @@ -223,7 +223,7 @@ func validateBindingArtifactAgainstContractV1(contract *BindingContractV1, artif if !compatibleTag { return fmt.Errorf("artifact tags are incompatible with the binding contract") } - pythonSpecifiers, err := pep440.NewSpecifiers(artifact.RequiresPython) + _, err = pep440.NewSpecifiers(artifact.RequiresPython) if err != nil { return fmt.Errorf("requires_python is invalid") } @@ -240,9 +240,13 @@ func validateBindingArtifactAgainstContractV1(contract *BindingContractV1, artif return fmt.Errorf("bundled component %q does not match the contract", declared.Name) } } - for _, version := range contract.SupportedPython { - parsed, err := pep440.Parse(version) - if err == nil && pythonSpecifiers.Check(parsed) { + claims, err := pythonprovider.NormalizeSupportedPythonClaimsV1(contract.SupportedPython) + if err != nil { + return fmt.Errorf("contract supported Python claims are invalid: %w", err) + } + for _, claim := range claims { + covered, err := pythonprovider.PythonRequiresPythonIntersectsClaimV1(artifact.RequiresPython, claim) + if err == nil && covered { return nil } } @@ -285,21 +289,21 @@ func validateTargetBindingsAgainstContractsV1(records map[string]loadedRecordV1, } func validateBindingInterpreterCoverageV1(contract *BindingContractV1, artifacts []*BindingArtifactRecordV1) error { - for _, version := range contract.SupportedPython { - parsed, err := pep440.Parse(version) - if err != nil { - return fmt.Errorf("contract interpreter %q is invalid", version) - } + claims, err := pythonprovider.NormalizeSupportedPythonClaimsV1(contract.SupportedPython) + if err != nil { + return fmt.Errorf("contract supported Python claims are invalid: %w", err) + } + for _, claim := range claims { covered := false for _, artifact := range artifacts { - specifiers, err := pep440.NewSpecifiers(artifact.RequiresPython) + matches, err := pythonprovider.PythonRequiresPythonCoversClaimV1(artifact.RequiresPython, claim) if err != nil { - return fmt.Errorf("artifact %q requires_python is invalid", artifact.ID) + return fmt.Errorf("artifact %q requires_python is invalid: %w", artifact.ID, err) } - covered = covered || specifiers.Check(parsed) + covered = covered || matches } if !covered { - return fmt.Errorf("binding %q has no artifact covering interpreter %q", contract.Name, version) + return fmt.Errorf("binding %q has no artifact covering interpreter %q", contract.Name, claim) } } return nil diff --git a/internal/toolcatalog/records_compose_test.go b/internal/toolcatalog/records_compose_test.go index 079c1197..669fa482 100644 --- a/internal/toolcatalog/records_compose_test.go +++ b/internal/toolcatalog/records_compose_test.go @@ -202,6 +202,31 @@ func TestBindingInterpreterCoverageRequiresEveryAdvertisedVersionV1(t *testing.T } } +func TestBindingInterpreterCoverageProvesCompleteMinorSeriesV1(t *testing.T) { + contract := &BindingContractV1{Name: "python", SupportedPython: []string{"3.14"}} + complete := &BindingArtifactRecordV1{ID: "complete", RequiresPython: ">=3.14,<3.15"} + if err := validateBindingInterpreterCoverageV1(contract, []*BindingArtifactRecordV1{complete}); err != nil { + t.Fatalf("complete minor coverage rejected: %v", err) + } + for _, requiresPython := range []string{"==3.14.0", ">=3.14,<3.14.1", ">=3.14,<3.15,!=3.14.1", ">=3.14,<3.15,!=3.14.1.0.*"} { + artifact := &BindingArtifactRecordV1{ID: "partial", RequiresPython: requiresPython} + if err := validateBindingInterpreterCoverageV1(contract, []*BindingArtifactRecordV1{artifact}); err == nil { + t.Errorf("partial minor coverage %q was accepted", requiresPython) + } + } + contract.SupportedPython = []string{"3.14.0"} + point := &BindingArtifactRecordV1{ID: "point", RequiresPython: ">=3.14,<3.14.1"} + if err := validateBindingInterpreterCoverageV1(contract, []*BindingArtifactRecordV1{point}); err != nil { + t.Fatalf("exact patch point coverage rejected: %v", err) + } + for _, requiresPython := range []string{"==3.14.0+vendor", "!=3.14.0+vendor"} { + local := &BindingArtifactRecordV1{ID: "local", RequiresPython: requiresPython} + if err := validateBindingInterpreterCoverageV1(contract, []*BindingArtifactRecordV1{local}); err == nil { + t.Errorf("local-version constraint %q was treated as public-release coverage", requiresPython) + } + } +} + func TestBindingArtifactsAgreeWithContractAndExactReferencesV1(t *testing.T) { values := validRecordValuesV1() contract := values[4].(*BindingContractV1)