From 688b3f9b82db516783d668a1f13cd2070f75d560 Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Thu, 10 Sep 2026 16:01:37 -0700 Subject: [PATCH] Add pypa/packaging conformance tests for PEP 440 specifier parsing Ports 118 cases from pypa/packaging's tests/test_specifiers.py to validate PythonVersionSpecifier against the reference PEP 440 implementation: - 31 invalid specifiers that must be rejected (test_specifiers_invalid) - 85 version/specifier match cases (test_specifiers) - 2 cases that intentionally differ from packaging, kept in their own suite Expected values were generated by calling packaging with its DEFAULT prerelease semantics, not the prereleases=True that the upstream harness forces. Cases using epochs, local version labels, post/dev releases, or more than three release segments are omitted: a Python interpreter version never has them, and PythonVersion deliberately does not model them. The two divergences are strict-inequality bounds naming a prerelease (>3.0.0a7 and <3.0.0a8). packaging enables prereleases only for ==, >=, <=, ~= and ===, so it filters them out; we enable prereleases for any clause naming one, which pythonVersionSpecifier.unit.test.ts already asserts for <3.14.0rc2. Test data is used under BSD-2-Clause; attribution is in the file header. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...honVersionSpecifier.packaging.unit.test.ts | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 src/test/common/pythonVersionSpecifier.packaging.unit.test.ts diff --git a/src/test/common/pythonVersionSpecifier.packaging.unit.test.ts b/src/test/common/pythonVersionSpecifier.packaging.unit.test.ts new file mode 100644 index 00000000..6c5e890d --- /dev/null +++ b/src/test/common/pythonVersionSpecifier.packaging.unit.test.ts @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// Test data in this file is derived from the pypa/packaging project. +// Source: https://github.com/pypa/packaging/blob/main/tests/test_specifiers.py +// Copyright: Donald Stufft and individual contributors +// License: BSD-2-Clause (packaging is dual-licensed Apache-2.0 / BSD-2-Clause) +// +// Expected values follow packaging's DEFAULT prerelease semantics. Upstream forces +// `prereleases=True`, which reports a different result for five wildcard and four +// `<=` cases below; do not "correct" them back to the upstream values. +// +// Cases using epochs, local version labels, post/dev releases, or more than three +// release segments are omitted: a Python interpreter version never has them, and +// PythonVersion deliberately does not model them. + +import assert from 'node:assert'; +import { PythonVersion } from '../../common/pythonVersion'; +import { PythonVersionSpecifier } from '../../common/pythonVersionSpecifier'; + +/** Mirrors `matchesPythonVersion`: malformed input yields `undefined`, never a throw. */ +function matches(version: string, specifier: string): boolean | undefined { + const parsedVersion = PythonVersion.tryParse(version); + const parsedSpecifier = PythonVersionSpecifier.tryParse(specifier); + return parsedVersion && parsedSpecifier ? parsedSpecifier.matches(parsedVersion) : undefined; +} + +const INVALID_SPECIFIERS: readonly string[] = [ + '2.0', + '=>2.0', + '==', + '~=1.0+5', + '>=1.0+deadbeef', + '<=1.0+abc123', + '>1.0+watwat', + '<1.0+1.0', + '~=1.0.*', + '>=1.0.*', + '<=1.0.*', + '>1.0.*', + '<1.0.*', + '==1.0.*+5', + '!=1.0.*+deadbeef', + '==2.0a1.*', + '!=2.0a1.*', + '==2.0.post1.*', + '!=2.0.post1.*', + '==2.0.dev1.*', + '!=2.0.dev1.*', + '==1.0+5.*', + '!=1.0+deadbeef.*', + '==1.0.*.5', + '~=1', + '==1.0.dev1.*', + '!=1.0.dev1.*', + '==1.2+\u0130', + '==1.2+\u0130\u0131\u017fK', + '~=1.2.3prev\u0131ew1', + '~=1.2.3po\u017ft1', +]; + +const MATCH_CASES: ReadonlyArray = [ + ['==2', '2.0', true], + ['==2.0', '2.0', true], + ['==2.0.0', '2.0', true], + ['==2.*', '2a1', false], + ['==2.*', '2b1', false], + ['==2.*', '2c1', false], + ['==2.*', '2rc1', false], + ['==2.0.*', '2rc1', false], + ['==2.*', '2', true], + ['==2.0.*', '2', true], + ['==2.0.0.*', '2', true], + ['==2.*', '2.0', true], + ['==2.*', '2.0.0', true], + ['!=2', '2.1', true], + ['!=2.0', '2.1', true], + ['!=2', '2.0.1', true], + ['!=2.0', '2.0.1', true], + ['!=2.0.0', '2.0.1', true], + ['!=3.*', '2.0', true], + ['!=2.0.*', '2.1', true], + ['!=2.0.0.*', '3', true], + ['>=2', '2.0', true], + ['>=2.0', '2.0', true], + ['>=2.0.0', '2.0', true], + ['>=2', '3', true], + ['>=3.0.0a7', '3.0.0a8', true], + ['<=2', '2.0', true], + ['<=2.0', '2.0', true], + ['<=2.0.0', '2.0', true], + ['<=2', '2.0a1', false], + ['<=2', '2.0b1', false], + ['<=2', '2.0c1', false], + ['<=2', '2.0rc1', false], + ['<=2', '1', true], + ['<=3.0.0a8', '3.0.0a7', true], + ['>2', '3', true], + ['>2.0', '2.1', true], + ['>2', '2.0.1', true], + ['<2', '1', true], + ['<2.1', '2.0', true], + ['~=1.0', '1', true], + ['~=1.0', '1.0.1', true], + ['~=1.0', '1.1', true], + ['~=1.0', '1.9999999', true], + ['~=1.0a1', '1.1', true], + ['~=2022.01.01', '2022.01.01', true], + ['==2', '2.1', false], + ['==2.0', '2.1', false], + ['==2.0.0', '2.1', false], + ['==3.*', '2.0', false], + ['==2.0.*', '2.1', false], + ['==2.0.0.*', '3', false], + ['!=2', '2.0', false], + ['!=2.0', '2.0', false], + ['!=2.0.0', '2.0', false], + ['!=2.*', '2a1', false], + ['!=2.*', '2b1', false], + ['!=2.*', '2c1', false], + ['!=2.*', '2rc1', false], + ['!=2.0.*', '2rc1', false], + ['!=2.*', '2', false], + ['!=2.0.*', '2', false], + ['!=2.0.0.*', '2', false], + ['!=2.*', '2.0', false], + ['!=2.*', '2.0.0', false], + ['>=2', '2.0a1', false], + ['>=2', '2.0b1', false], + ['>=2', '2.0c1', false], + ['>=2', '2.0rc1', false], + ['>=2', '1', false], + ['<=2', '3', false], + ['>2', '1', false], + ['>2', '2.0a1', false], + ['>2', '2.0b1', false], + ['>2', '2.0c1', false], + ['>2', '2.0rc1', false], + ['>2', '2.0', false], + ['<2', '2.0a1', false], + ['<2', '2.0b1', false], + ['<2', '2.0c1', false], + ['<2', '2.0rc1', false], + ['<2', '2.0', false], + ['<2', '3', false], + ['~=1.0', '2.0', false], + ['~=1.0.0', '1.1.0', false], +]; + +// packaging enables prereleases only for ==, >=, <=, ~= and ===; we enable them for any +// clause naming one, so these two cases intentionally differ from packaging's default. +const INTENTIONAL_DIVERGENCES: ReadonlyArray = [ + ['>3.0.0a7', '3.0.0a8', true], + ['<3.0.0a8', '3.0.0a7', true], +]; + +suite('PythonVersionSpecifier packaging conformance', () => { + suite('rejects specifiers packaging rejects', () => { + for (const specifier of INVALID_SPECIFIERS) { + test(`rejects ${JSON.stringify(specifier)}`, () => { + assert.strictEqual(PythonVersionSpecifier.tryParse(specifier), undefined); + }); + } + }); + + suite('matches packaging semantics', () => { + for (const [specifier, version, expected] of MATCH_CASES) { + test(`${specifier} ${expected ? 'matches' : 'does not match'} ${version}`, () => { + assert.strictEqual(matches(version, specifier), expected); + }); + } + }); + + suite('diverges from packaging by design', () => { + for (const [specifier, version, expected] of INTENTIONAL_DIVERGENCES) { + test(`${specifier} ${expected ? 'matches' : 'does not match'} ${version}`, () => { + assert.strictEqual(matches(version, specifier), expected); + }); + } + }); +});