11type PythonReleaseLevel = 'alpha' | 'beta' | 'candidate' | 'final' ;
2+ type ComparisonOperator = '~=' | '==' | '!=' | '>=' | '<=' | '>' | '<' ;
3+ type ParsedClause =
4+ | { readonly operator : '===' ; readonly expected : string }
5+ | { readonly operator : '==' | '!=' ; readonly wildcard : readonly number [ ] }
6+ | { readonly operator : ComparisonOperator ; readonly expected : PythonVersion } ;
27
38export class PythonVersion {
49 private static readonly VERSION_PATTERN =
@@ -43,12 +48,15 @@ export class PythonVersion {
4348 }
4449
4550 this . original = normalizedVersion ;
46- this . releaseComponentCount = match [ 3 ] !== undefined ? 3 : match [ 2 ] !== undefined ? 2 : 1 ;
51+ this . releaseComponentCount = match [ 3 ] !== undefined ? 3 : match [ 2 ] !== undefined ? 2 : 1 ;
4752 this . major = parseNumericComponent ( match [ 1 ] , version ) ;
4853 this . minor = parseNumericComponent ( match [ 2 ] , version ) ;
4954 this . patch = parseNumericComponent ( match [ 3 ] , version ) ;
5055 this . releaseLevel = PythonVersion . normalizeReleaseLevel ( match [ 4 ] ?? match [ 6 ] ) ;
5156 this . releaseSerial = parseNumericComponent ( match [ 5 ] ?? match [ 7 ] , version ) ;
57+ if ( this . releaseLevel === 'final' && this . releaseSerial !== 0 ) {
58+ throw new TypeError ( `Invalid Python version: ${ version } ` ) ;
59+ }
5260 }
5361
5462 readonly major : number ;
@@ -86,9 +94,7 @@ export class PythonVersion {
8694 */
8795 compareTo ( other : PythonVersion ) : number {
8896 return (
89- compareNumbers ( this . major , other . major ) ||
90- compareNumbers ( this . minor , other . minor ) ||
91- compareNumbers ( this . patch , other . patch ) ||
97+ this . compareReleaseTo ( other ) ||
9298 compareNumbers (
9399 PythonVersion . RELEASE_LEVEL_ORDER [ this . releaseLevel ] ,
94100 PythonVersion . RELEASE_LEVEL_ORDER [ other . releaseLevel ] ,
@@ -120,15 +126,18 @@ export class PythonVersion {
120126 * @returns `true` when every clause matches; otherwise `false`.
121127 */
122128 satisfies ( specifier : unknown ) : boolean {
123- if ( typeof specifier !== 'string' ) {
124- return false ;
125- }
129+ return this . matchSpecifier ( specifier ) ?? false ;
130+ }
126131
127- const clauses = specifier
128- . split ( ',' )
129- . map ( ( clause ) => clause . trim ( ) )
130- . filter ( ( clause ) => clause . length > 0 ) ;
131- return clauses . length > 0 && clauses . every ( ( clause ) => this . satisfiesClause ( clause ) ) ;
132+ /**
133+ * Evaluates a Python version specifier while distinguishing invalid syntax.
134+ *
135+ * @param specifier The specifier to evaluate.
136+ * @returns Whether this version matches, or `undefined` for an invalid specifier.
137+ */
138+ matchSpecifier ( specifier : unknown ) : boolean | undefined {
139+ const clauses = PythonVersion . parseSpecifier ( specifier ) ;
140+ return clauses ?. every ( ( clause ) => this . satisfiesClause ( clause ) ) ;
132141 }
133142
134143 /** Returns the normalized Python version representation. */
@@ -150,37 +159,57 @@ export class PythonVersion {
150159 return value ? ( PythonVersion . RELEASE_LEVEL_ALIASES [ value . toLowerCase ( ) ] ?? 'final' ) : 'final' ;
151160 }
152161
153- private satisfiesClause ( clause : string ) : boolean {
162+ private static parseSpecifier ( specifier : unknown ) : readonly ParsedClause [ ] | undefined {
163+ if ( typeof specifier !== 'string' ) {
164+ return undefined ;
165+ }
166+
167+ const clauses = specifier
168+ . split ( ',' )
169+ . map ( ( clause ) => clause . trim ( ) )
170+ . filter ( ( clause ) => clause . length > 0 ) ;
171+ if ( clauses . length === 0 ) {
172+ return undefined ;
173+ }
174+
175+ const parsed = clauses . map ( ( clause ) => PythonVersion . parseClause ( clause ) ) ;
176+ return parsed . every ( ( clause ) : clause is ParsedClause => clause !== undefined ) ? parsed : undefined ;
177+ }
178+
179+ private static parseClause ( clause : string ) : ParsedClause | undefined {
154180 const match = PythonVersion . SPECIFIER_PATTERN . exec ( clause ) ;
155181 if ( ! match ) {
156- return false ;
182+ return undefined ;
157183 }
158184
159185 const operator = match [ 1 ] ;
160186 const expected = match [ 2 ] . trim ( ) ;
161187 if ( operator === '===' ) {
162- return this . original . replace ( / ^ v / i , '' ) === expected . replace ( / ^ v / i , '' ) ;
188+ return expected . length > 0 ? { operator , expected } : undefined ;
163189 }
164-
165190 if ( expected . endsWith ( '.*' ) ) {
166- if ( operator !== '==' && operator !== '!=' ) {
167- return false ;
168- }
169- const expectedComponents = PythonVersion . parseWildcard ( expected ) ;
170- if ( ! expectedComponents ) {
171- return false ;
172- }
173- const matches = this . matchesReleaseComponents ( expectedComponents ) ;
174- return operator === '==' ? matches : ! matches ;
191+ const wildcard = PythonVersion . parseWildcard ( expected ) ;
192+ return ( operator === '==' || operator === '!=' ) && wildcard ? { operator, wildcard } : undefined ;
175193 }
176194
177195 const expectedVersion = PythonVersion . tryParse ( expected ) ;
178- if ( ! expectedVersion ) {
179- return false ;
196+ if ( ! expectedVersion || ( operator === '~=' && expectedVersion . releaseComponentCount < 2 ) ) {
197+ return undefined ;
198+ }
199+ return { operator : operator as ComparisonOperator , expected : expectedVersion } ;
200+ }
201+
202+ private satisfiesClause ( clause : ParsedClause ) : boolean {
203+ if ( clause . operator === '===' ) {
204+ return this . original . replace ( / ^ v / i, '' ) === clause . expected . replace ( / ^ v / i, '' ) ;
205+ }
206+ if ( 'wildcard' in clause ) {
207+ const matches = this . matchesReleaseComponents ( clause . wildcard ) ;
208+ return clause . operator === '==' ? matches : ! matches ;
180209 }
181210
182- const comparison = this . compareReleaseTo ( expectedVersion ) ;
183- switch ( operator ) {
211+ const comparison = this . compareReleaseTo ( clause . expected ) ;
212+ switch ( clause . operator ) {
184213 case '==' :
185214 return comparison === 0 ;
186215 case '!=' :
@@ -195,10 +224,9 @@ export class PythonVersion {
195224 return comparison < 0 ;
196225 case '~=' :
197226 return (
198- expectedVersion . releaseComponentCount >= 2 &&
199227 comparison >= 0 &&
200228 this . matchesReleaseComponents (
201- expectedVersion . releaseComponents . slice ( 0 , expectedVersion . releaseComponentCount - 1 ) ,
229+ clause . expected . releaseComponents . slice ( 0 , clause . expected . releaseComponentCount - 1 ) ,
202230 )
203231 ) ;
204232 default :
0 commit comments