From 64a0caf7a4ce572b5ef211f0dc02bb03eb8c2fa9 Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Sat, 12 Sep 2026 13:00:30 +0200 Subject: [PATCH] PTD-23.1.1: Canonicalize Python support claims Add provider-owned minor-series and exact-patch claim normalization and intersection. Enforce canonical claims in binding records and solver accumulation, including cumulative active-provider intersection coverage. --- .../+portable-python-claim-intersection.yaml | 2 + internal/portabletool/python.go | 29 ---- internal/portabletool/record_validate.go | 37 +++- internal/portabletool/record_validate_test.go | 54 ++++++ internal/providers/portable_tool_dag.go | 16 +- internal/providers/portable_tool_dag_test.go | 14 ++ internal/providers/portable_tool_python.go | 4 - internal/providers/python/version.go | 9 + internal/providers/python_claim.go | 160 ++++++++++++++++++ internal/providers/python_claim_test.go | 68 ++++++++ internal/toolcatalog/solver.go | 44 +++-- internal/toolcatalog/solver_test.go | 51 +++++- 12 files changed, 428 insertions(+), 60 deletions(-) create mode 100644 .changes/unreleased/+portable-python-claim-intersection.yaml create mode 100644 internal/providers/python_claim.go create mode 100644 internal/providers/python_claim_test.go diff --git a/.changes/unreleased/+portable-python-claim-intersection.yaml b/.changes/unreleased/+portable-python-claim-intersection.yaml new file mode 100644 index 00000000..7a6f6fcd --- /dev/null +++ b/.changes/unreleased/+portable-python-claim-intersection.yaml @@ -0,0 +1,2 @@ +kind: Fixed +body: Preserve compatible Python minor-series and exact-patch claims when portable bindings share an interpreter domain. diff --git a/internal/portabletool/python.go b/internal/portabletool/python.go index c37fa1e2..2679e30a 100644 --- a/internal/portabletool/python.go +++ b/internal/portabletool/python.go @@ -148,35 +148,6 @@ func PythonPackageRootRequirementsCompatibleV1(requirements []string) (bool, err return !portableToolPythonIntervalCoveredV1(interval, excludedPrefixes), nil } -// portableToolPythonSupportedIntersectionV1 reports whether all nonempty -// supported-Python sets share at least one version. Empty sets are unconstrained -// in the same way as an omitted binding constraint. -func PythonSupportedIntersectionV1(supported [][]string) bool { - var intersection map[string]struct{} - for _, values := range supported { - if len(values) == 0 { - continue - } - current := make(map[string]struct{}, len(values)) - for _, value := range values { - current[value] = struct{}{} - } - if intersection == nil { - intersection = current - continue - } - for value := range intersection { - if _, exists := current[value]; !exists { - delete(intersection, value) - } - } - if len(intersection) == 0 { - return false - } - } - return true -} - type portableToolPythonRequirementBoundV1 struct { version []int inclusive bool diff --git a/internal/portabletool/record_validate.go b/internal/portabletool/record_validate.go index 61c7f43d..16ddb64f 100644 --- a/internal/portabletool/record_validate.go +++ b/internal/portabletool/record_validate.go @@ -181,14 +181,9 @@ func validatePortableToolCatalogBindingContractV1(value canonical.Object) error } distributions[distribution] = requirement } - if err := validatePortableToolCatalogSortedStringsV1("supported Python", record.SupportedPython, true); err != nil { + if err := validatePortableToolSupportedPythonClaimsV1(record.SupportedPython); err != nil { return err } - for _, version := range record.SupportedPython { - if err := ValidatePythonInterpreterVersionV1(version); err != nil { - return fmt.Errorf("supported Python version %q: %w", version, err) - } - } if record.BundledComponents == nil || len(record.BundledComponents) > portableToolCatalogMaxReferencesV1 { return fmt.Errorf("binding contract bundled components must use a bounded array") } @@ -212,6 +207,36 @@ func validatePortableToolCatalogBindingContractV1(value canonical.Object) error return nil } +func validatePortableToolSupportedPythonClaimsV1(values []string) error { + if values == nil { + return fmt.Errorf("supported Python must use an array") + } + if len(values) == 0 { + return fmt.Errorf("supported Python must not be empty") + } + if len(values) > portableToolCatalogMaxReferencesV1 { + return fmt.Errorf("supported Python must use at most %d entries", portableToolCatalogMaxReferencesV1) + } + var previous []int + for _, value := range values { + if err := ValidatePythonInterpreterVersionV1(value); err != nil { + return fmt.Errorf("supported Python version %q: %w", value, err) + } + current, ok := portableToolPythonParseReleaseVersionV1(value) + if !ok { + return fmt.Errorf("supported Python version %q is not canonical", value) + } + if previous != nil { + comparison := portableToolPythonCompareReleaseVersionsV1(previous, current) + if comparison >= 0 || len(previous) == 2 && previous[0] == current[0] && previous[1] == current[1] { + return fmt.Errorf("supported Python must contain numerically sorted normalized claims") + } + } + previous = current + } + return nil +} + func validatePortableToolCatalogBindingArtifactV1(value canonical.Object) error { const schema = BindingArtifactSchemaV1 var record portableToolCatalogBindingArtifactV1 diff --git a/internal/portabletool/record_validate_test.go b/internal/portabletool/record_validate_test.go index 5e60465c..56f19257 100644 --- a/internal/portabletool/record_validate_test.go +++ b/internal/portabletool/record_validate_test.go @@ -9,6 +9,7 @@ import ( "github.com/omry/reploy/internal/canonical" "github.com/omry/reploy/internal/portabletool" + "github.com/omry/reploy/internal/providers" "github.com/omry/reploy/internal/toolcatalog" ) @@ -66,6 +67,59 @@ func TestValidateRecordEnvelopeV1RejectsNoncanonicalRecordID(t *testing.T) { } } +func TestValidateRecordEnvelopeV1RequiresNormalizedSupportedPythonClaims(t *testing.T) { + t.Parallel() + for _, claims := range [][]any{ + {"3.10.1", "3.10"}, + {"3.10", "3.10.1"}, + {"3.10", "3.9"}, + {"3.10.1", "3.10.1"}, + } { + value := readDefinitionObjectV1(t, "playwright/releases/1.61.0/bindings/python/contract.json") + value["supported_python"] = claims + if err := portabletool.ValidateRecordEnvelopeV1(canonical.Envelope{ + Schema: portabletool.BindingContractSchemaV1, + Value: value, + }); err == nil { + t.Errorf("non-normalized supported Python claims %#v were accepted", claims) + } + } + + value := readDefinitionObjectV1(t, "playwright/releases/1.61.0/bindings/python/contract.json") + value["supported_python"] = []any{"3.9", "3.10.1", "3.10.2"} + if err := portabletool.ValidateRecordEnvelopeV1(canonical.Envelope{ + Schema: portabletool.BindingContractSchemaV1, + Value: value, + }); err != nil { + t.Fatalf("normalized exact patch claims were rejected: %v", err) + } +} + +func TestProviderCanonicalSupportedPythonClaimsSatisfyRecordBoundaryV1(t *testing.T) { + t.Parallel() + for _, input := range [][]string{ + {"3.10.2", "3.9", "3.10", "3.10.1", "3.9", "4.0.1"}, + {"3.12.2", "3.12.1", "3.11"}, + } { + normalized, err := providers.NormalizeSupportedPythonClaimsV1(input) + if err != nil { + t.Fatalf("normalize %#v: %v", input, err) + } + claims := make([]any, len(normalized)) + for index, claim := range normalized { + claims[index] = claim + } + value := readDefinitionObjectV1(t, "playwright/releases/1.61.0/bindings/python/contract.json") + value["supported_python"] = claims + if err := portabletool.ValidateRecordEnvelopeV1(canonical.Envelope{ + Schema: portabletool.BindingContractSchemaV1, + Value: value, + }); err != nil { + t.Fatalf("provider canonical claims %#v rejected at record boundary: %v", normalized, err) + } + } +} + func TestValidateRecordEnvelopeV1RejectsShortPayloadID(t *testing.T) { t.Parallel() value := readDefinitionObjectV1(t, "playwright/releases/1.61.0/payloads/chromium/chromium-linux-amd64.json") diff --git a/internal/providers/portable_tool_dag.go b/internal/providers/portable_tool_dag.go index d6364f08..797ab57f 100644 --- a/internal/providers/portable_tool_dag.go +++ b/internal/providers/portable_tool_dag.go @@ -565,7 +565,7 @@ func validatePortableToolProviderSharedClaimsV1( claims := make(map[string]claim) filesystemClaims := make(map[string][]portableToolFilesystemClaimV1) pythonRequirements := make(map[string][]string) - pythonSupported := make(map[string][][]string) + pythonSupported := make(map[string][]string) addClaim := func(key, value, owner string) error { if previous, exists := claims[key]; exists { if previous.value != value { @@ -619,10 +619,20 @@ func validatePortableToolProviderSharedClaimsV1( } if supportedPresent { key := domain.PackageManager.ID - pythonSupported[key] = append(pythonSupported[key], supported) - if !portableToolPythonSupportedIntersectionV1(pythonSupported[key]) { + intersection, err := NormalizeSupportedPythonClaimsV1(supported) + if err != nil { + return fmt.Errorf("binding contract supported Python: %w", err) + } + if previous, found := pythonSupported[key]; found { + intersection, err = IntersectSupportedPythonClaimsV1(previous, intersection) + if err != nil { + return fmt.Errorf("binding contract supported Python: %w", err) + } + } + if len(intersection) == 0 { return fmt.Errorf("portable tool provider shared-domain conflict on Python interpreter domain %q", key) } + pythonSupported[key] = intersection } } } diff --git a/internal/providers/portable_tool_dag_test.go b/internal/providers/portable_tool_dag_test.go index 9d68815c..30b3a606 100644 --- a/internal/providers/portable_tool_dag_test.go +++ b/internal/providers/portable_tool_dag_test.go @@ -435,6 +435,20 @@ func TestBuildPortableToolProviderDAGV1ComparesBindingPythonSemantics(t *testing if _, err := BuildPortableToolProviderDAGV1(portableToolProviderPlanFixtureV1(), plan, domains); err != nil { t.Fatalf("compatible Python constraints rejected: %v", err) } + mixedGranularity := clonePortableToolPlanForTest(plan) + setPortableToolBindingPythonSemanticsV1(&mixedGranularity.Tools[0].Responsibilities.BindingContracts[0], []string{"demo>=1"}, []string{"3.12"}) + setPortableToolBindingPythonSemanticsV1(&mixedGranularity.Tools[1].Responsibilities.BindingContracts[0], []string{"demo<3"}, []string{"3.12.7"}) + if _, err := BuildPortableToolProviderDAGV1(portableToolProviderPlanFixtureV1(), mixedGranularity, domains); err != nil { + t.Fatalf("compatible Python series and exact patch rejected: %v", err) + } + + disjointExactPatches := clonePortableToolPlanForTest(plan) + setPortableToolBindingPythonSemanticsV1(&disjointExactPatches.Tools[0].Responsibilities.BindingContracts[0], []string{"demo>=1"}, []string{"3.12.6"}) + setPortableToolBindingPythonSemanticsV1(&disjointExactPatches.Tools[1].Responsibilities.BindingContracts[0], []string{"demo<3"}, []string{"3.12.7"}) + if _, err := BuildPortableToolProviderDAGV1(portableToolProviderPlanFixtureV1(), disjointExactPatches, domains); err == nil || !strings.Contains(err.Error(), "shared-domain conflict") { + t.Fatalf("disjoint exact Python patches were not rejected: %v", err) + } + canonicalArrays := clonePortableToolPlanForTest(plan) canonicalContract := &canonicalArrays.Tools[1].Responsibilities.BindingContracts[0] canonicalContract.Record.Value["requirements"] = []any{"demo<3"} diff --git a/internal/providers/portable_tool_python.go b/internal/providers/portable_tool_python.go index f6238ead..f894107d 100644 --- a/internal/providers/portable_tool_python.go +++ b/internal/providers/portable_tool_python.go @@ -14,7 +14,3 @@ func PythonPackageRootDistributionNameV1(requirement string) (string, error) { func PythonPackageRootRequirementsCompatibleV1(requirements []string) (bool, error) { return portabletool.PythonPackageRootRequirementsCompatibleV1(requirements) } - -func portableToolPythonSupportedIntersectionV1(supported [][]string) bool { - return portabletool.PythonSupportedIntersectionV1(supported) -} diff --git a/internal/providers/python/version.go b/internal/providers/python/version.go index c7055f3f..80e8803f 100644 --- a/internal/providers/python/version.go +++ b/internal/providers/python/version.go @@ -7,6 +7,7 @@ import ( pep440 "github.com/aquasecurity/go-pep440-version" "github.com/omry/reploy/internal/portabletool" + providerapi "github.com/omry/reploy/internal/providers" ) // ValidatePackageVersionV1 accepts one exact Python distribution version. @@ -28,6 +29,14 @@ func ValidateInterpreterVersionV1(value string) error { return portabletool.ValidatePythonInterpreterVersionV1(value) } +func NormalizeSupportedPythonClaimsV1(values []string) ([]string, error) { + return providerapi.NormalizeSupportedPythonClaimsV1(values) +} + +func IntersectSupportedPythonClaimsV1(left, right []string) ([]string, error) { + return providerapi.IntersectSupportedPythonClaimsV1(left, right) +} + // ComparePackageVersionsV1 compares valid PEP 440 versions. func ComparePackageVersionsV1(left string, right string) (int, error) { leftVersion, err := pep440.Parse(left) diff --git a/internal/providers/python_claim.go b/internal/providers/python_claim.go new file mode 100644 index 00000000..a5089ce2 --- /dev/null +++ b/internal/providers/python_claim.go @@ -0,0 +1,160 @@ +package providers + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/omry/reploy/internal/portabletool" +) + +// SupportedPythonClaimV1 is the provider-owned representation of one +// supported interpreter claim. Exact is false for a complete minor series and +// true for one complete major.minor.patch release. +type SupportedPythonClaimV1 struct { + Major int + Minor int + Patch int + Exact bool +} + +// ParseSupportedPythonClaimV1 validates and parses one canonical claim. +func ParseSupportedPythonClaimV1(value string) (SupportedPythonClaimV1, error) { + if err := portabletool.ValidatePythonInterpreterVersionV1(value); err != nil { + return SupportedPythonClaimV1{}, err + } + parts := strings.Split(value, ".") + major, _ := strconv.Atoi(parts[0]) + minor, _ := strconv.Atoi(parts[1]) + claim := SupportedPythonClaimV1{Major: major, Minor: minor} + if len(parts) == 3 { + claim.Patch, _ = strconv.Atoi(parts[2]) + claim.Exact = true + } + return claim, nil +} + +func (claim SupportedPythonClaimV1) String() string { + if claim.Exact { + return fmt.Sprintf("%d.%d.%d", claim.Major, claim.Minor, claim.Patch) + } + return fmt.Sprintf("%d.%d", claim.Major, claim.Minor) +} + +func compareSupportedPythonClaimsV1(left, right SupportedPythonClaimV1) int { + if left.Major != right.Major { + if left.Major < right.Major { + return -1 + } + return 1 + } + if left.Minor != right.Minor { + if left.Minor < right.Minor { + return -1 + } + return 1 + } + if left.Exact != right.Exact { + if !left.Exact { + return -1 + } + return 1 + } + if !left.Exact || left.Patch == right.Patch { + return 0 + } + if left.Patch < right.Patch { + return -1 + } + return 1 +} + +func sameSupportedPythonMinorV1(left, right SupportedPythonClaimV1) bool { + return left.Major == right.Major && left.Minor == right.Minor +} + +// NormalizeSupportedPythonClaimsV1 sorts claims numerically, removes +// duplicates, and removes exact releases subsumed by a minor-series claim. +func NormalizeSupportedPythonClaimsV1(values []string) ([]string, error) { + claims := make([]SupportedPythonClaimV1, 0, len(values)) + for _, value := range values { + claim, err := ParseSupportedPythonClaimV1(value) + if err != nil { + return nil, err + } + claims = append(claims, claim) + } + sort.Slice(claims, func(left, right int) bool { + return compareSupportedPythonClaimsV1(claims[left], claims[right]) < 0 + }) + result := make([]string, 0, len(claims)) + seriesByMinor := make(map[[2]int]struct{}) + for index, claim := range claims { + if index > 0 && claims[index-1] == claim { + continue + } + minorKey := [2]int{claim.Major, claim.Minor} + if !claim.Exact { + seriesByMinor[minorKey] = struct{}{} + } else if _, subsumed := seriesByMinor[minorKey]; subsumed { + continue + } + result = append(result, claim.String()) + } + return result, nil +} + +// IntersectSupportedPythonClaimsV1 performs one linear intersection over +// normalized claims. A series intersects every exact patch in that series. +func IntersectSupportedPythonClaimsV1(left, right []string) ([]string, error) { + leftNormalized, err := NormalizeSupportedPythonClaimsV1(left) + if err != nil { + return nil, err + } + rightNormalized, err := NormalizeSupportedPythonClaimsV1(right) + if err != nil { + return nil, err + } + leftClaims := make([]SupportedPythonClaimV1, len(leftNormalized)) + rightClaims := make([]SupportedPythonClaimV1, len(rightNormalized)) + for index, value := range leftNormalized { + leftClaims[index], _ = ParseSupportedPythonClaimV1(value) + } + for index, value := range rightNormalized { + rightClaims[index], _ = ParseSupportedPythonClaimV1(value) + } + result := []string{} + for leftIndex, rightIndex := 0, 0; leftIndex < len(leftClaims) && rightIndex < len(rightClaims); { + leftClaim, rightClaim := leftClaims[leftIndex], rightClaims[rightIndex] + if !sameSupportedPythonMinorV1(leftClaim, rightClaim) { + if compareSupportedPythonClaimsV1(leftClaim, rightClaim) < 0 { + leftIndex++ + } else { + rightIndex++ + } + continue + } + switch { + case !leftClaim.Exact && rightClaim.Exact: + result = append(result, rightClaim.String()) + rightIndex++ + case leftClaim.Exact && !rightClaim.Exact: + result = append(result, leftClaim.String()) + leftIndex++ + case !leftClaim.Exact && !rightClaim.Exact: + result = append(result, leftClaim.String()) + leftIndex++ + rightIndex++ + case leftClaim.Patch == rightClaim.Patch: + result = append(result, leftClaim.String()) + leftIndex++ + rightIndex++ + case leftClaim.Patch < rightClaim.Patch: + leftIndex++ + default: + rightIndex++ + } + } + return NormalizeSupportedPythonClaimsV1(result) +} diff --git a/internal/providers/python_claim_test.go b/internal/providers/python_claim_test.go new file mode 100644 index 00000000..7082a834 --- /dev/null +++ b/internal/providers/python_claim_test.go @@ -0,0 +1,68 @@ +package providers + +import ( + "reflect" + "testing" +) + +func TestSupportedPythonClaimsNormalizationLawsV1(t *testing.T) { + inputs := [][]string{ + {"3.10.2", "3.9", "3.10", "3.10.1", "3.9", "4.0.1"}, + {"4.0.1", "3.9", "3.10.1", "3.10", "3.9", "3.10.2"}, + } + want := []string{"3.9", "3.10", "4.0.1"} + for _, input := range inputs { + normalized, err := NormalizeSupportedPythonClaimsV1(input) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(normalized, want) { + t.Fatalf("normalized = %#v, want %#v", normalized, want) + } + normalizedAgain, err := NormalizeSupportedPythonClaimsV1(normalized) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(normalizedAgain, normalized) { + t.Fatalf("normalization is not idempotent: %#v then %#v", normalized, normalizedAgain) + } + if len(normalized) > len(input) { + t.Fatalf("normalization expanded %d claims to %d", len(input), len(normalized)) + } + } +} + +func TestSupportedPythonClaimsIntersectionSemanticsV1(t *testing.T) { + tests := []struct { + name string + left, right []string + want []string + }{ + {name: "matching series", left: []string{"3.12"}, right: []string{"3.12"}, want: []string{"3.12"}}, + {name: "series and patches", left: []string{"3.12"}, right: []string{"3.12.1", "3.12.7", "3.13"}, want: []string{"3.12.1", "3.12.7"}}, + {name: "matching exact patch", left: []string{"3.12.7"}, right: []string{"3.12.7"}, want: []string{"3.12.7"}}, + {name: "unequal series", left: []string{"3.12"}, right: []string{"3.13"}, want: []string{}}, + {name: "unequal exact patches", left: []string{"3.12.6"}, right: []string{"3.12.7"}, want: []string{}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := IntersectSupportedPythonClaimsV1(test.left, test.right) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, test.want) { + t.Fatalf("intersection = %#v, want %#v", got, test.want) + } + reversed, err := IntersectSupportedPythonClaimsV1(test.right, test.left) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(reversed, test.want) { + t.Fatalf("reversed intersection = %#v, want %#v", reversed, test.want) + } + if len(got) > len(test.left)+len(test.right) { + t.Fatalf("intersection expanded %d inputs to %d results", len(test.left)+len(test.right), len(got)) + } + }) + } +} diff --git a/internal/toolcatalog/solver.go b/internal/toolcatalog/solver.go index 4e1222a2..42409154 100644 --- a/internal/toolcatalog/solver.go +++ b/internal/toolcatalog/solver.go @@ -336,9 +336,16 @@ func validateActiveProviderConstraintSourceV1(index int, if err := validateSortedUniqueStringsV1("requirements", item.Requirements, false); err != nil { return err } - if err := validateSortedUniqueStringsV1("supported Python", item.SupportedPython, false); err != nil { + if item.SupportedPython == nil || len(item.SupportedPython) > maxDefinitionReferences { + return fmt.Errorf("supported Python must use a bounded array") + } + claims, err := pythonprovider.NormalizeSupportedPythonClaimsV1(item.SupportedPython) + if err != nil { return err } + if !stringSlicesEqualV1(claims, item.SupportedPython) { + return fmt.Errorf("supported Python claims are not canonically normalized") + } if len(item.Requirements) == 0 && len(item.SupportedPython) == 0 { return fmt.Errorf("must contribute a requirement or interpreter constraint") } @@ -790,28 +797,41 @@ func addBindingRequirementClaimV1(claims *assignmentClaimsV1, domain string, return "", nil } +func stringSlicesEqualV1(left, right []string) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} + func addPythonInterpreterClaimV1(claims *assignmentClaimsV1, domain string, supported []string, owner string) string { + normalized, err := pythonprovider.NormalizeSupportedPythonClaimsV1(supported) + if err != nil { + return fmt.Sprintf("invalid Python interpreter claim from %s: %v", owner, err) + } + if len(normalized) == 0 { + return "" + } if claims.pythonInterpreters == nil { claims.pythonInterpreters = make(map[string]pythonInterpreterClaimV1) } previous, exists := claims.pythonInterpreters[domain] if !exists { claims.pythonInterpreters[domain] = pythonInterpreterClaimV1{ - owners: []string{owner}, constraints: [][]string{append([]string{}, supported...)}, - supported: append([]string{}, supported...), + owners: []string{owner}, constraints: [][]string{append([]string{}, normalized...)}, + supported: append([]string{}, normalized...), } return "" } - available := make(map[string]struct{}, len(previous.supported)) - for _, version := range previous.supported { - available[version] = struct{}{} - } - intersection := make([]string, 0, len(supported)) - for _, version := range supported { - if _, ok := available[version]; ok { - intersection = append(intersection, version) - } + intersection, err := pythonprovider.IntersectSupportedPythonClaimsV1(previous.supported, normalized) + if err != nil { + return fmt.Sprintf("invalid Python interpreter claim from %s: %v", owner, err) } if len(intersection) == 0 { return fmt.Sprintf("Python interpreter conflict in domain %q among %s", diff --git a/internal/toolcatalog/solver_test.go b/internal/toolcatalog/solver_test.go index f4a0619b..46df9ed9 100644 --- a/internal/toolcatalog/solver_test.go +++ b/internal/toolcatalog/solver_test.go @@ -1,6 +1,7 @@ package toolcatalog import ( + "reflect" "strings" "testing" @@ -446,9 +447,9 @@ func TestBindingClaimsRequireSharedPythonInterpreterV1(t *testing.T) { source := sourceSet.Candidates[1] bindingLeft := cloneBindingContractV1(validRecordValuesV1()[4].(*BindingContractV1)) - bindingLeft.SupportedPython = []string{"3.11", "3.12"} + bindingLeft.SupportedPython = []string{"3.12"} bindingRight := cloneBindingContractV1(&bindingLeft) - bindingRight.SupportedPython = []string{"3.12", "3.13"} + bindingRight.SupportedPython = []string{"3.12.2"} application.Contributions = []RecordReferenceV1{solverTestAddRecordV1(t, catalog, &bindingLeft)} source.Contributions = []RecordReferenceV1{solverTestAddRecordV1(t, catalog, &bindingRight)} @@ -464,14 +465,26 @@ func TestBindingClaimsRequireSharedPythonInterpreterV1(t *testing.T) { t.Fatalf("overlapping supported Python sets conflict = %q, %v", conflict, err) } + bindingLeft.SupportedPython = []string{"3.12.2"} + application.Contributions = []RecordReferenceV1{solverTestAddRecordV1(t, catalog, &bindingLeft)} bindingDisjoint := cloneBindingContractV1(&bindingLeft) - bindingDisjoint.SupportedPython = []string{"3.13"} + bindingDisjoint.SupportedPython = []string{"3.12.3"} source.Contributions = []RecordReferenceV1{solverTestAddRecordV1(t, catalog, &bindingDisjoint)} conflict, err := catalog.assignmentConflictV1( sets, []ReleaseCandidateV1{application, source}, domains, solverTestActiveProvidersV1()) if err != nil || !strings.Contains(conflict, "Python interpreter conflict") { t.Fatalf("disjoint supported Python sets conflict = %q, %v", conflict, err) } + + bindingLeft.SupportedPython = []string{"3.12"} + application.Contributions = []RecordReferenceV1{solverTestAddRecordV1(t, catalog, &bindingLeft)} + bindingDisjoint.SupportedPython = []string{"3.13"} + source.Contributions = []RecordReferenceV1{solverTestAddRecordV1(t, catalog, &bindingDisjoint)} + conflict, err = catalog.assignmentConflictV1( + sets, []ReleaseCandidateV1{application, source}, domains, solverTestActiveProvidersV1()) + if err != nil || !strings.Contains(conflict, "Python interpreter conflict") { + t.Fatalf("unequal minor-series claims conflict = %q, %v", conflict, err) + } } func TestPayloadClaimsUseEffectiveRuntimeDestinationV1(t *testing.T) { @@ -620,7 +633,7 @@ func TestActiveProviderConstraintsAcceptEveryCanonicalFamilyV1(t *testing.T) { Manager: "apt", Requirements: []string{"curl"}, Repositories: []string{"debian-main"}, }} source.PythonBindings = []ActivePythonBindingConstraintV1{{ - Name: "python", Requirements: []string{"support>=1"}, SupportedPython: []string{"3.11"}, + Name: "python", Requirements: []string{"support>=1"}, SupportedPython: []string{"3.11.4"}, }} source.InstallRoots = []string{"/srv/active"} source.OwnedPaths = []ActiveFilesystemConstraintV1{{Path: "/srv/active/data", Digest: digest}} @@ -645,6 +658,32 @@ func TestActiveProviderConstraintsAcceptEveryCanonicalFamilyV1(t *testing.T) { } } +func TestActiveProviderPythonClaimRetainsCumulativeIntersectionV1(t *testing.T) { + catalog := candidateTestCatalogV1(t) + domains := solverTestBuildDomainsV1(false) + source := solverTestActiveSourceV1( + "source-builder:demo", "active-provider", "application/current") + source.PythonBindings = []ActivePythonBindingConstraintV1{{ + Name: "python", SupportedPython: []string{"3.11"}, + }} + claims := assignmentClaimsV1{} + if conflict, err := catalog.addActiveProviderClaimsV1( + &claims, domains[:1], solverTestActiveProvidersV1(source)); err != nil || conflict != "" { + t.Fatalf("seed active-provider series claim = %q, %v", conflict, err) + } + if conflict := addPythonInterpreterClaimV1( + &claims, domains[0].PackageManager, []string{"3.11.4"}, "matching exact patch"); conflict != "" { + t.Fatalf("intersect active-provider series with exact patch: %s", conflict) + } + if got := claims.pythonInterpreters[domains[0].PackageManager].supported; !reflect.DeepEqual(got, []string{"3.11.4"}) { + t.Fatalf("retained supported Python = %#v, want exact intersection", got) + } + if conflict := addPythonInterpreterClaimV1( + &claims, domains[0].PackageManager, []string{"3.11.5"}, "unequal exact patch"); !strings.Contains(conflict, "Python interpreter conflict") { + t.Fatalf("later unequal exact patch conflict = %q", conflict) + } +} + func TestActiveProviderConstraintsRespectIsolatedDomainsV1(t *testing.T) { catalog := candidateTestCatalogV1(t) applicationSet, sourceSet := solverTestCandidateSetsV1(t, catalog) @@ -762,10 +801,10 @@ func TestActiveProviderConstraintsSeedEveryClaimFamilyV1(t *testing.T) { {name: "Python interpreter", want: "Python interpreter conflict", mutate: func(candidate *ReleaseCandidateV1, source *ActiveProviderConstraintSourceV1) { binding := cloneBindingContractV1(validRecordValuesV1()[4].(*BindingContractV1)) binding.Requirements = []string{"demo>=1"} - binding.SupportedPython = []string{"3.12"} + binding.SupportedPython = []string{"3.12.1"} candidate.Contributions = []RecordReferenceV1{solverTestAddRecordV1(t, catalog, &binding)} source.PythonBindings = []ActivePythonBindingConstraintV1{{ - Name: "python", Requirements: []string{}, SupportedPython: []string{"3.11"}, + Name: "python", Requirements: []string{}, SupportedPython: []string{"3.12.2"}, }} }}, {name: "install root", want: "filesystem conflict", mutate: func(candidate *ReleaseCandidateV1, source *ActiveProviderConstraintSourceV1) {