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
2 changes: 2 additions & 0 deletions .changes/unreleased/+portable-python-claim-intersection.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
kind: Fixed
body: Preserve compatible Python minor-series and exact-patch claims when portable bindings share an interpreter domain.
29 changes: 0 additions & 29 deletions internal/portabletool/python.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 31 additions & 6 deletions internal/portabletool/record_validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -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)
}
Comment thread
omry marked this conversation as resolved.
Comment thread
gitar-bot[bot] marked this conversation as resolved.
Comment thread
omry marked this conversation as resolved.
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
Expand Down
54 changes: 54 additions & 0 deletions internal/portabletool/record_validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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")
Expand Down
16 changes: 13 additions & 3 deletions internal/providers/portable_tool_dag.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}
}
Expand Down
14 changes: 14 additions & 0 deletions internal/providers/portable_tool_dag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
4 changes: 0 additions & 4 deletions internal/providers/portable_tool_python.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
9 changes: 9 additions & 0 deletions internal/providers/python/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand Down
Loading
Loading