Skip to content

Commit 84e38e9

Browse files
authored
Add PEP 440 version comparison for pypi scheme (#26)
* Add PEP 440 version comparison for pypi scheme parseDotSeparated silently dropped non-numeric segment suffixes, so 5.2b1 parsed as 5.0.0 and every PEP 440 prerelease form collapsed to the release. There was also no pypi case in CompareWithScheme, and Range.Contains used generic comparison regardless of scheme. Add a PEP 440 comparator covering epoch, release padding, pre/post/dev ordering, spelling and separator normalization, and local version segments. Store the scheme on Range so Contains can dispatch to it. Split trailing letter suffixes in parseDotSeparated instead of discarding them. Fixes #24 * Address review findings on PEP 440 comparison Range.IsEmpty used generic comparison, so a pypi range like [1.0.dev1, 1.0a1) reported empty and serialized as vers:pypi/. Use the scheme comparator there too. Scheme was not set on ranges from the wildcard path, ParseNative sub-parsers that bypass parseConstraints (including pypi ~=), or the results of Union/Intersect/Exclude. Stamp it in each of those places so Contains and IsEmpty on the result use the right rules. Replace the 1<<30 positive-infinity sentinel in the PEP 440 comparator with explicit hasPre/hasPost/hasDev flags. Numeric components are unbounded in PEP 440 and a large enough dev number sorted the wrong side of an absent one. * Thread scheme comparator through interval algebra; compare PEP 440 numerics as strings Range.Intersect and Range.Union built results using generic comparison, so intersecting >=1.0.dev1 with <1.0a1 under pypi discarded the resulting interval as empty. Add cmp-taking variants of Interval.Intersect/Union/Overlaps/Adjacent and mergeIntervals, and have Range.Intersect/Union use the scheme comparator. Store PEP 440 numeric components (epoch, release segments, pre/post/ dev numbers, numeric local segments) as digit strings and compare them by length then lexically. Values that overflow int no longer collapse to zero, and large local segments stay numeric. * Derive pypi ~= upper bound from PEP 440 release segments; use scheme equality for Union exclusions parsePypiRange delegated ~= to the gem pessimistic algorithm, which counts raw dots (so .dev/.post added phantom segments) and drops the epoch. ~=1.4.dev1 produced <1.5 instead of <2, and ~=1!1.4.5 lost the epoch on the upper bound. Add a pypi-specific handler that reads the release segments and epoch via parsePEP440 and increments the second-last release segment. Stop trimming trailing zeros from the parsed release so ~=2.0.dev1 sees two segments; cmpNumStrSlice already treats missing segments as zero so comparison is unchanged. Range.Union intersected exclusions by string equality, so unioning ranges that exclude 1.5 and 1.5.0 respectively dropped the exclusion even though those are the same version under PEP 440. Compare with the scheme comparator instead. * Parse comma-separated pypi specifiers individually and intersect parsePypiRange checked for a leading ~= before splitting on comma, so ~=1.4.2,!=1.4.5 fed the whole tail into the compatible-release handler and lost the exclusion. The comma path also rewrote parts to pipe-separated constraints, which never re-entered ~= handling, so >=1.0,~=1.4 treated ~=1.4 as an exact version and left the range unbounded above. Split on comma first, parse each part on its own (so ~= is handled per part), and Range.Intersect the results. Comma is AND in PEP 440. * Set pypi scheme on ~= result before intersection parsePypiCompatibleRelease returned a Range with an empty Scheme, relying on the ParseNative wrapper to stamp it afterwards. When ~= was the first clause in a comma-separated set, the subsequent Intersect ran under generic comparison and discarded intervals like [1.0.dev1, 1.0a1) as empty. Set the scheme on the ~= result (and its fallback) so intersection order does not matter.
1 parent fa68a6c commit 84e38e9

6 files changed

Lines changed: 979 additions & 70 deletions

File tree

interval.go

Lines changed: 55 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,16 @@ func LessThanInterval(version string, inclusive bool) Interval {
4848

4949
// IsEmpty returns true if this interval matches no versions.
5050
func (i Interval) IsEmpty() bool {
51+
return i.isEmptyCmp(CompareVersions)
52+
}
53+
54+
func (i Interval) isEmptyCmp(cmp func(a, b string) int) bool {
5155
if i.Min != "" && i.Max != "" {
52-
cmp := CompareVersions(i.Min, i.Max)
53-
if cmp > 0 {
56+
c := cmp(i.Min, i.Max)
57+
if c > 0 {
5458
return true
5559
}
56-
if cmp == 0 && (!i.MinInclusive || !i.MaxInclusive) {
60+
if c == 0 && (!i.MinInclusive || !i.MaxInclusive) {
5761
return true
5862
}
5963
}
@@ -67,7 +71,11 @@ func (i Interval) IsUnbounded() bool {
6771

6872
// Contains checks if the interval contains the given version.
6973
func (i Interval) Contains(version string) bool {
70-
if i.IsEmpty() {
74+
return i.containsCmp(version, CompareVersions)
75+
}
76+
77+
func (i Interval) containsCmp(version string, cmp func(a, b string) int) bool {
78+
if i.isEmptyCmp(cmp) {
7179
return false
7280
}
7381
if i.IsUnbounded() {
@@ -76,27 +84,27 @@ func (i Interval) Contains(version string) bool {
7684

7785
// Check minimum bound
7886
if i.Min != "" {
79-
cmp := CompareVersions(version, i.Min)
87+
c := cmp(version, i.Min)
8088
if i.MinInclusive {
81-
if cmp < 0 {
89+
if c < 0 {
8290
return false
8391
}
8492
} else {
85-
if cmp <= 0 {
93+
if c <= 0 {
8694
return false
8795
}
8896
}
8997
}
9098

9199
// Check maximum bound
92100
if i.Max != "" {
93-
cmp := CompareVersions(version, i.Max)
101+
c := cmp(version, i.Max)
94102
if i.MaxInclusive {
95-
if cmp > 0 {
103+
if c > 0 {
96104
return false
97105
}
98106
} else {
99-
if cmp >= 0 {
107+
if c >= 0 {
100108
return false
101109
}
102110
}
@@ -107,7 +115,11 @@ func (i Interval) Contains(version string) bool {
107115

108116
// Intersect returns the intersection of two intervals.
109117
func (i Interval) Intersect(other Interval) Interval {
110-
if i.IsEmpty() || other.IsEmpty() {
118+
return i.intersectCmp(other, CompareVersions)
119+
}
120+
121+
func (i Interval) intersectCmp(other Interval, cmp func(a, b string) int) Interval {
122+
if i.isEmptyCmp(cmp) || other.isEmptyCmp(cmp) {
111123
return EmptyInterval()
112124
}
113125

@@ -116,12 +128,12 @@ func (i Interval) Intersect(other Interval) Interval {
116128
// Determine new minimum
117129
switch {
118130
case i.Min != "" && other.Min != "":
119-
cmp := CompareVersions(i.Min, other.Min)
131+
c := cmp(i.Min, other.Min)
120132
switch {
121-
case cmp > 0:
133+
case c > 0:
122134
result.Min = i.Min
123135
result.MinInclusive = i.MinInclusive
124-
case cmp < 0:
136+
case c < 0:
125137
result.Min = other.Min
126138
result.MinInclusive = other.MinInclusive
127139
default:
@@ -139,12 +151,12 @@ func (i Interval) Intersect(other Interval) Interval {
139151
// Determine new maximum
140152
switch {
141153
case i.Max != "" && other.Max != "":
142-
cmp := CompareVersions(i.Max, other.Max)
154+
c := cmp(i.Max, other.Max)
143155
switch {
144-
case cmp < 0:
156+
case c < 0:
145157
result.Max = i.Max
146158
result.MaxInclusive = i.MaxInclusive
147-
case cmp > 0:
159+
case c > 0:
148160
result.Max = other.Max
149161
result.MaxInclusive = other.MaxInclusive
150162
default:
@@ -164,23 +176,31 @@ func (i Interval) Intersect(other Interval) Interval {
164176

165177
// Overlaps returns true if the two intervals overlap.
166178
func (i Interval) Overlaps(other Interval) bool {
167-
if i.IsEmpty() || other.IsEmpty() {
179+
return i.overlapsCmp(other, CompareVersions)
180+
}
181+
182+
func (i Interval) overlapsCmp(other Interval, cmp func(a, b string) int) bool {
183+
if i.isEmptyCmp(cmp) || other.isEmptyCmp(cmp) {
168184
return false
169185
}
170-
return !i.Intersect(other).IsEmpty()
186+
return !i.intersectCmp(other, cmp).isEmptyCmp(cmp)
171187
}
172188

173189
// Adjacent returns true if the two intervals are adjacent (can be merged).
174190
func (i Interval) Adjacent(other Interval) bool {
175-
if i.IsEmpty() || other.IsEmpty() {
191+
return i.adjacentCmp(other, CompareVersions)
192+
}
193+
194+
func (i Interval) adjacentCmp(other Interval, cmp func(a, b string) int) bool {
195+
if i.isEmptyCmp(cmp) || other.isEmptyCmp(cmp) {
176196
return false
177197
}
178198

179-
if i.Max != "" && other.Min != "" && CompareVersions(i.Max, other.Min) == 0 {
199+
if i.Max != "" && other.Min != "" && cmp(i.Max, other.Min) == 0 {
180200
return (i.MaxInclusive && !other.MinInclusive) || (!i.MaxInclusive && other.MinInclusive)
181201
}
182202

183-
if i.Min != "" && other.Max != "" && CompareVersions(i.Min, other.Max) == 0 {
203+
if i.Min != "" && other.Max != "" && cmp(i.Min, other.Max) == 0 {
184204
return (i.MinInclusive && !other.MaxInclusive) || (!i.MinInclusive && other.MaxInclusive)
185205
}
186206

@@ -189,14 +209,18 @@ func (i Interval) Adjacent(other Interval) bool {
189209

190210
// Union returns the union of two intervals, or nil if they cannot be merged.
191211
func (i Interval) Union(other Interval) *Interval {
192-
if i.IsEmpty() {
212+
return i.unionCmp(other, CompareVersions)
213+
}
214+
215+
func (i Interval) unionCmp(other Interval, cmp func(a, b string) int) *Interval {
216+
if i.isEmptyCmp(cmp) {
193217
return &other
194218
}
195-
if other.IsEmpty() {
219+
if other.isEmptyCmp(cmp) {
196220
return &i
197221
}
198222

199-
if !i.Overlaps(other) && !i.Adjacent(other) {
223+
if !i.overlapsCmp(other, cmp) && !i.adjacentCmp(other, cmp) {
200224
return nil
201225
}
202226

@@ -207,12 +231,12 @@ func (i Interval) Union(other Interval) *Interval {
207231
result.Min = ""
208232
result.MinInclusive = false
209233
} else {
210-
cmp := CompareVersions(i.Min, other.Min)
234+
c := cmp(i.Min, other.Min)
211235
switch {
212-
case cmp < 0:
236+
case c < 0:
213237
result.Min = i.Min
214238
result.MinInclusive = i.MinInclusive
215-
case cmp > 0:
239+
case c > 0:
216240
result.Min = other.Min
217241
result.MinInclusive = other.MinInclusive
218242
default:
@@ -226,12 +250,12 @@ func (i Interval) Union(other Interval) *Interval {
226250
result.Max = ""
227251
result.MaxInclusive = false
228252
} else {
229-
cmp := CompareVersions(i.Max, other.Max)
253+
c := cmp(i.Max, other.Max)
230254
switch {
231-
case cmp > 0:
255+
case c > 0:
232256
result.Max = i.Max
233257
result.MaxInclusive = i.MaxInclusive
234-
case cmp < 0:
258+
case c < 0:
235259
result.Max = other.Max
236260
result.MaxInclusive = other.MaxInclusive
237261
default:

parser.go

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,20 +28,30 @@ func (p *Parser) Parse(versURI string) (*Range, error) {
2828

2929
// Handle wildcard for unbounded range
3030
if constraintsStr == "*" || constraintsStr == "" {
31-
return Unbounded(), nil
31+
r := Unbounded()
32+
r.Scheme = scheme
33+
return r, nil
3234
}
3335

3436
return p.parseConstraints(constraintsStr, scheme)
3537
}
3638

3739
// ParseNative parses a native package manager version range into a Range.
3840
func (p *Parser) ParseNative(constraint string, scheme string) (*Range, error) {
41+
r, err := p.parseNative(constraint, scheme)
42+
if r != nil && r.Scheme == "" {
43+
r.Scheme = scheme
44+
}
45+
return r, err
46+
}
47+
48+
func (p *Parser) parseNative(constraint string, scheme string) (*Range, error) {
3949
switch scheme {
4050
case "npm":
4151
return p.parseNpmRange(constraint)
4252
case "gem", "rubygems":
4353
return p.parseGemRange(constraint)
44-
case "pypi":
54+
case "pypi": //nolint:goconst
4555
return p.parsePypiRange(constraint)
4656
case "maven":
4757
return p.parseMavenRange(constraint)
@@ -215,7 +225,7 @@ func (p *Parser) parseConstraints(constraintsStr, scheme string) (*Range, error)
215225

216226
// Collect all intervals - they form a union
217227
// Then intersect overlapping intervals to form proper ranges
218-
result := intersectConsecutiveIntervals(intervals)
228+
result := intersectConsecutiveIntervals(intervals, scheme)
219229

220230
// If we only have exclusions and no other constraints, start with unbounded range
221231
if result == nil {
@@ -226,20 +236,23 @@ func (p *Parser) parseConstraints(constraintsStr, scheme string) (*Range, error)
226236
}
227237
}
228238
result.Exclusions = exclusions
239+
result.Scheme = scheme
229240
return result, nil
230241
}
231242

232243
// intersectConsecutiveIntervals handles VERS constraint semantics:
233244
// - Consecutive unbounded intervals (like >=X followed by <Y) are intersected to form a range
234245
// - Bounded intervals (exact versions) are unioned
235-
func intersectConsecutiveIntervals(intervals []Interval) *Range {
246+
func intersectConsecutiveIntervals(intervals []Interval, scheme string) *Range {
236247
if len(intervals) == 0 {
237248
return nil
238249
}
239250
if len(intervals) == 1 {
240251
return NewRange(intervals)
241252
}
242253

254+
cmp := compareFuncFor(scheme)
255+
243256
var resultIntervals []Interval
244257
i := 0
245258
for i < len(intervals) {
@@ -252,7 +265,7 @@ func intersectConsecutiveIntervals(intervals []Interval) *Range {
252265
if (current.Min != "" && current.Max == "" && next.Max != "" && next.Min == "") ||
253266
(current.Max != "" && current.Min == "" && next.Min != "" && next.Max == "") {
254267
intersection := current.Intersect(next)
255-
if !intersection.IsEmpty() {
268+
if !intersection.isEmptyCmp(cmp) {
256269
resultIntervals = append(resultIntervals, intersection)
257270
i += 2
258271
continue
@@ -295,7 +308,7 @@ func (p *Parser) parseNpmRange(s string) (*Range, error) {
295308
}
296309
}
297310
return &Range{
298-
Intervals: mergeIntervals(allIntervals),
311+
Intervals: mergeIntervals(allIntervals, CompareVersions),
299312
Exclusions: allExclusions,
300313
RawConstraints: allRaw,
301314
}, nil
@@ -572,22 +585,53 @@ func (p *Parser) parsePessimisticRange(version string) (*Range, error) {
572585
func (p *Parser) parsePypiRange(s string) (*Range, error) {
573586
s = strings.TrimSpace(s)
574587

588+
// Comma is AND in PEP 440: parse each specifier and intersect.
589+
if strings.Contains(s, ",") {
590+
var result *Range
591+
for _, part := range strings.Split(s, ",") {
592+
r, err := p.parsePypiRange(part)
593+
if err != nil {
594+
return nil, err
595+
}
596+
if result == nil {
597+
result = r
598+
} else {
599+
result = result.Intersect(r)
600+
}
601+
}
602+
return result, nil
603+
}
604+
575605
// Compatible release: ~=1.4.2
576606
if strings.HasPrefix(s, "~=") {
577607
version := strings.TrimSpace(s[2:])
578-
return p.parsePessimisticRange(version)
579-
}
580-
581-
// Comma-separated constraints
582-
if strings.Contains(s, ",") {
583-
parts := strings.Split(s, ",")
584-
constraintStr := strings.Join(parts, "|")
585-
return p.parseConstraints(constraintStr, "pypi")
608+
return p.parsePypiCompatibleRelease(version)
586609
}
587610

588611
return p.parseConstraints(s, "pypi")
589612
}
590613

614+
// parsePypiCompatibleRelease handles PEP 440 ~= by deriving the upper bound
615+
// from release segments (ignoring pre/post/dev) and preserving the epoch.
616+
func (p *Parser) parsePypiCompatibleRelease(version string) (*Range, error) {
617+
upper, ok := pep440CompatibleUpper(version)
618+
var r *Range
619+
if ok {
620+
r = NewRange([]Interval{NewInterval(version, upper, true, false)})
621+
} else {
622+
// Fall back to the generic pessimistic algorithm when the
623+
// version is not valid PEP 440 or has fewer than two release
624+
// segments.
625+
var err error
626+
r, err = p.parsePessimisticRange(version)
627+
if err != nil {
628+
return nil, err
629+
}
630+
}
631+
r.Scheme = "pypi" //nolint:goconst
632+
return r, nil
633+
}
634+
591635
// maven: [1.0,2.0), (1.0,2.0], [1.0,)
592636
func (p *Parser) parseMavenRange(s string) (*Range, error) {
593637
s = strings.TrimSpace(s)
@@ -701,7 +745,7 @@ func (p *Parser) parseHexRange(s string) (*Range, error) {
701745
}
702746
}
703747
return &Range{
704-
Intervals: mergeIntervals(allIntervals),
748+
Intervals: mergeIntervals(allIntervals, CompareVersions),
705749
RawConstraints: allRaw,
706750
}, nil
707751
}

0 commit comments

Comments
 (0)