You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
I ran a review of packaging with Fable; I've had great success with these so far, the highest signal/noise ratio I've seen for an automated review.
π€ AI text below π€
The review is done β six parallel agents covered the codebase and docs, and I independently reproduced every high-severity claim. Here's the full report, most important first.
Release-blocking bug (unreleased regression)
_ranges.py:106-122 β _BoundaryVersion ordering is wrong across post-release families. When two boundaries have different base versions, comparison falls back to plain Version ordering, but AFTER_POSTS(1.0) actually sits above1.0.post1's entire family. The wrong ordering reaches max() in intersect_ranges, widening intersections. Verified on main (26.2 returns the correct result, so this came in with #1120, unreleased):
SpecifierSet(">1.0,!=1.0.post1").contains("1.0.post2") β True (must be False; per-spec conjunction and 26.2 both say False)
Fuzzing found 28 divergences vs the all(spec.contains(...)) ground truth, all of the form >V β§ !=V.postN. Relatedly, _ranges.py:213-219's AFTER_POSTS else-branch in _UpperBound (pragma: no cover) would be semantically wrong if ever reached β worth an assert instead of a silent fallback.
Other bugs (all verified by execution)
markers.py
markers.py:172-177 β _normalize_extra_values only normalizes top-level atoms, so parenthesized extra == clauses never get PEP 685 normalization: Marker('(extra == "Foo_Bar" or extra == "Baz") and python_version >= "3"').evaluate({"extra": "foo-bar"}) β False (should be True). The eval-time _normalize skips work on the false premise that "both sides are normalized already". Reachable via Requirement markers too. -> PR fix: normalize nested extra marker valuesΒ #1246
markers.py:156-169 β if the other side of an extra comparison is a Variable, it's destructively rewritten into a literal: Marker('os_name == extra') becomes '"os-name" == extra' and evaluates against the literal string.
_parser.py:372-374 β ast.literal_eval failures escape as raw SyntaxError instead of InvalidMarker/InvalidRequirement: Marker('os_name == "C:\\"'). Invalid escape sequences also emit SyntaxWarning, which is an error under pytest's filterwarnings = ["error"]. -> PR fix(markers): wrap malformed quoted strings as public parse errorsΒ #1249
metadata.py:631-639 β a legally folded Description-Content-Type: header escapes from_email() as a bare ValueError ("Header values may not contain linefeed...") from the EmailMessage assignment used for validation, bypassing the error collector entirely. -> PR fix(metadata): aggregate from_email validation errorsΒ #1268
metadata.py:846-860 β from_email raises the "unparsed" group before running validation, so invalid-field errors are silently dropped when both kinds exist, despite the docstring's "all exceptions will be gathered". -> PR fix(metadata): aggregate from_email validation errorsΒ #1268
dependency_groups.py
dependency_groups.py:260 β _parse_group caches partial results even when errors were collected, so the secondlookup() of a bad group silently returns () where the first raised (verified). Contradicts the tested re-raise invariant for cycles. -> PR Fix dependency group malformed parse cachingΒ #1248
dependency_groups.py:255-256 β {include-group = 5} (valid TOML) escapes as raw TypeError from _normalize_name instead of a collected validation error. -> PR Fix dependency group malformed parse cachingΒ #1248
licenses/init.py
LicenseRef-custom+ raises bare KeyError (the dict is keyed with the +, lookup strips it). Per PEP 639/SPDX grammar, + isn't allowed on a LicenseRef at all, so the fix is to reject with InvalidLicenseExpression. Leaks through Metadata.license_expression too. -> PR fix: raise correct exception on invalid licenseΒ #1219
markers.py:222 β _eval_op re-parses Specifier(f"{op}{rhs}") on every evaluation; an lru_cache would eliminate it on the resolver hot path. -> PR perf(markers): cache Specifier parsing in _eval_opΒ #1251 (Henry: I didn't like adding a cache here)
specifiers.py:556 β Specifier.__hash__ recomputes _canonical_spec each call (~13Γ slower than Version's cached hash); Marker.__hash__/Requirement.__hash__ similarly re-serialize the whole tree. All three are immutable β cache like Version does (PR #1118). -> PR perf: cache __hash__ on Specifier, Marker, and RequirementΒ #1252
specifiers.py:843 β _canonical_specs resets the _is_unsatisfiable/_ranges caches even though canonicalization is semantics-preserving, so str()/hash()/== after contains() throws away the expensive range intersection. -> PR perf(specifiers): keep range caches across canonicalizationΒ #1253
_manylinux.py:191 β import _manylinux runs inside the per-arch Γ per-glibc loop; failed imports aren't cached by sys.modules, costing ~1-2 ms per full sys_tags() on typical systems. Resolve once before the loop. -> PR perf(manylinux): cache _manylinux module lookup process-wideΒ #1254
pylock.py + direct_url.py β ~150 lines duplicated nearly verbatim (_get, _get_required, _get_object, and character-for-character identical error classes). A shared private module would keep them from drifting β the bool-rejection fix above currently needs making twice.
Smaller: duplicate if version >= (11, 0): blocks in mac_platforms (tags.py:652/662); dead py2 guard in _manylinux.py:144; _GLibCVersion(*...) re-wrap at _manylinux.py:240; set(parsed.extras or []) dead or [] (requirements.py:70); double str.lower pass in _process_dynamic (metadata.py:664); wrong struct-format comments in _elffile.py:60-85 (parsing itself verified correct); unused misspelled EMachine.AArc64; expect() docstring says the token is not read but it returns self.read() (_tokenizer.py:138). -> PR refactor: small cleanups from the #1239 reviewΒ #1281 (the two _manylinux.py items are false positives β exercised by tests; see PR)
Modernizations
Little to do β slots, cached hashes, and prebound predicates are already in good shape. Two notes:
Otherwise verified clean: all 274 doctests pass, docs build clean under -W -n, all spot-checked versionadded annotations match git history.
What's verified clean: the version.py parse/compare core (differentially fuzzed against 26.2 across ~7k combinations β zero divergences outside the _ranges bug), the specifier fast paths, ELF parsing, manylinux/musllinux semantics, macOS/iOS/Android tag ordering, URL/extras parsing in _parser.py, and _structures.py/errors.py.
The one thing I'd fix before anything else is the _ranges.py boundary-ordering bug β it's an unreleased correctness regression in SpecifierSet.contains/filter, exactly the hot path resolvers depend on.
I ran a review of packaging with Fable; I've had great success with these so far, the highest signal/noise ratio I've seen for an automated review.
π€ AI text below π€
The review is done β six parallel agents covered the codebase and docs, and I independently reproduced every high-severity claim. Here's the full report, most important first.
Release-blocking bug (unreleased regression)
_ranges.py:106-122β_BoundaryVersionordering is wrong across post-release families. When two boundaries have different base versions, comparison falls back to plainVersionordering, butAFTER_POSTS(1.0)actually sits above1.0.post1's entire family. The wrong ordering reachesmax()inintersect_ranges, widening intersections. Verified on main (26.2 returns the correct result, so this came in with #1120, unreleased):SpecifierSet(">1.0,!=1.0.post1").contains("1.0.post2")βTrue(must beFalse; per-spec conjunction and 26.2 both sayFalse)SpecifierSet(">1.0,<=1.0.post1").is_unsatisfiable()βFalse(it's empty)Fuzzing found 28 divergences vs the
all(spec.contains(...))ground truth, all of the form>Vβ§!=V.postN. Relatedly,_ranges.py:213-219'sAFTER_POSTSelse-branch in_UpperBound(pragma: no cover) would be semantically wrong if ever reached β worth an assert instead of a silent fallback.Other bugs (all verified by execution)
markers.py
markers.py:172-177β_normalize_extra_valuesonly normalizes top-level atoms, so parenthesizedextra ==clauses never get PEP 685 normalization:Marker('(extra == "Foo_Bar" or extra == "Baz") and python_version >= "3"').evaluate({"extra": "foo-bar"})βFalse(should beTrue). The eval-time_normalizeskips work on the false premise that "both sides are normalized already". Reachable viaRequirementmarkers too. -> PR fix: normalize nested extra marker valuesΒ #1246markers.py:156-169β if the other side of anextracomparison is a Variable, it's destructively rewritten into a literal:Marker('os_name == extra')becomes'"os-name" == extra'and evaluates against the literal string.markers.py:278β set-valued lockfile variables on the LHS hit a bareAssertionError:Marker("dependency_groups == 'foo'").evaluate(context="lock_file"). -> PR fix(markers): raise UndefinedComparison for set-valued variables used outside the membership formΒ #1265markers.py:208-217β side effect of #939:===on non-version keys now raisesUndefinedComparison(Marker("os_name === 'posix'").evaluate()); β€25.0 evaluated it as string equality. Untested either way β decide and pin it. -> PR test(markers): pin === (arbitrary equality) evaluation on non-version keys (#1239)Β #1279_parser.py:372-374βast.literal_evalfailures escape as rawSyntaxErrorinstead ofInvalidMarker/InvalidRequirement:Marker('os_name == "C:\\"'). Invalid escape sequences also emitSyntaxWarning, which is an error under pytest'sfilterwarnings = ["error"]. -> PR fix(markers): wrap malformed quoted strings as public parse errorsΒ #1249requirements.py
requirements.py:139-152β__eq__compares canonicalizedSpecifierSets but__hash__hashes raw strings:Requirement("pkg==1.0") == Requirement("pkg==1.0.0")isTruewith different hashes, breaking set/dict usage. -> PR fix(requirements): make Requirement.__hash__ consistent with __eq__ for trailing-zero-equivalent specifiersΒ #1232metadata.py
metadata.py:631-639β a legally foldedDescription-Content-Type:header escapesfrom_email()as a bareValueError("Header values may not contain linefeed...") from theEmailMessageassignment used for validation, bypassing the error collector entirely. -> PR fix(metadata): aggregate from_email validation errorsΒ #1268metadata.py:225-241β amultipart/mixedMETADATA payload raises bareAssertionError(and under-Oreturns a list ofMessageobjects as the description). -> PR fix(metadata): route multipart payloads to unparsed instead of assertingΒ #1247metadata.py:846-860βfrom_emailraises the "unparsed" group before running validation, so invalid-field errors are silently dropped when both kinds exist, despite the docstring's "all exceptions will be gathered". -> PR fix(metadata): aggregate from_email validation errorsΒ #1268dependency_groups.py
dependency_groups.py:260β_parse_groupcaches partial results even when errors were collected, so the secondlookup()of a bad group silently returns()where the first raised (verified). Contradicts the tested re-raise invariant for cycles. -> PR Fix dependency group malformed parse cachingΒ #1248dependency_groups.py:255-256β{include-group = 5}(valid TOML) escapes as rawTypeErrorfrom_normalize_nameinstead of a collected validation error. -> PR Fix dependency group malformed parse cachingΒ #1248licenses/init.py
LicenseRef-custom+raises bareKeyError(the dict is keyed with the+, lookup strips it). Per PEP 639/SPDX grammar,+isn't allowed on a LicenseRef at all, so the fix is to reject withInvalidLicenseExpression. Leaks throughMetadata.license_expressiontoo. -> PR fix: raise correct exception on invalid licenseΒ #1219WITHchaining and non-simple left operands are accepted (mit WITH X WITH X,(mit or apache-2.0) WITH X) β all invalid SPDX. -> PR fix: reject more invalid license expression formsΒ #1266LicenseRef-(empty idstring) is accepted; the validation regex uses*instead of+. -> PR fix: reject more invalid license expression formsΒ #1266tags.py
tags.py:385β the explicit-ABI dedup tuple("abi3", "none")was never updated forabi3t: passingabis=['cp315t','abi3t']yieldscp315-abi3t-plattwice (verified), andabis=['abi3t']alone emits an inconsistent non-threaded abi3 chain afterward. -> PR fix: duplicate explicit abi3t tagsΒ #1245tags.py:898βsys_tags(warn=True)doesn't forwardwarntogeneric_tags(), silently dropping warnings on PyPy/GraalPy. -> PR fix(tags): forward warn to generic_tags() in sys_tagsΒ #1264tags.py:248βparse_tag("py3-none")raises bare unpackingValueErrorinstead of the new 26.3InvalidTag. -> PR fix(tags): raise InvalidTag for malformed tag field countsΒ #1238tags.py:437β empty-stringEXT_SUFFIXraisesIndexErrorinstead of the intendedSystemError. -> PR fix(tags): raise SystemError (not IndexError) for empty EXT_SUFFIX in _generic_abiΒ #1271_musllinux.py:87β the__main__debug block passes astrwhereSequence[str]is expected, iterating characters and printing garbage tags. -> PR fix(_musllinux): pass archs as a list in the __main__ debug blockΒ #1280version.py / pylock.py / direct_url.py / tooling
version.py:1072-1081β_TrimmedReleasecopies_key_cachebut not_hash_cache;hash()raisesAttributeError(latent, internal-only). -> PR fix(version): set _hash_cache when copying a Version in _TrimmedReleaseΒ #1242version.py:265-313βVersion.from_partsacceptsboolfor int fields (post=Trueβ unparseable'1.postTrue'), and a non-strpreletter escapes asAttributeErrorinstead ofInvalidVersion. -> PR fix(version): raise InvalidVersion for non-str pre letters in from_partsΒ #1241pylock.py:104β_getacceptsboolforintfields (size = truevalidates);pylock.py:593-605β emptywheels = []defeats the mutual-exclusivity check againstvcs/directory/archive;pylock.py:796βselect()KeyErrors on environments missingpython_full_versiondespite adjacent code anticipating partial environments. -> PR fix(pylock): reject bools where TOML integers are expectedΒ #1244direct_url.py:286-290βfile://scheme check is case-sensitive and rejects RFC 8089file:/path. -> PR fix(direct_url): parse URL scheme case-insensitively when checking for file URLsΒ #1240noxfile.py:324βnox -s release_build -- 26.2has never worked:git switch <tag>without--detachfails. Alsorelease_buildisn'tdefault=False, so barenoxruns it and errors on non-tagged commits. Andasv.conf.json:4hashttps//github.com(missing colon). -> PR chore(noxfile): detach on tag checkout and mark release_build non-default; fix asv URL typoΒ #1243Performance
markers.py:462βMarker.evaluaterebuildsdefault_environment()every call (~25% of evaluate time measured); the values are process-constant. -> PR perf(markers): cache the default environmentΒ #1250markers.py:222β_eval_opre-parsesSpecifier(f"{op}{rhs}")on every evaluation; anlru_cachewould eliminate it on the resolver hot path. -> PR perf(markers): cache Specifier parsing in _eval_opΒ #1251 (Henry: I didn't like adding a cache here)specifiers.py:556βSpecifier.__hash__recomputes_canonical_speceach call (~13Γ slower thanVersion's cached hash);Marker.__hash__/Requirement.__hash__similarly re-serialize the whole tree. All three are immutable β cache likeVersiondoes (PR #1118). -> PR perf: cache __hash__ on Specifier, Marker, and RequirementΒ #1252specifiers.py:843β_canonical_specsresets the_is_unsatisfiable/_rangescaches even though canonicalization is semantics-preserving, sostr()/hash()/==aftercontains()throws away the expensive range intersection. -> PR perf(specifiers): keep range caches across canonicalizationΒ #1253_manylinux.py:191βimport _manylinuxruns inside the per-arch Γ per-glibc loop; failed imports aren't cached bysys.modules, costing ~1-2 ms per fullsys_tags()on typical systems. Resolve once before the loop. -> PR perf(manylinux): cache _manylinux module lookup process-wideΒ #1254utils.py:222β one inlinere.match(r"^[\w\d._]*$", ...)where every sibling pattern is precompiled (\dis also redundant inside\w). -> PR perf(utils): precompile the wheel project-name patternΒ #1256tags.py:619βmac_platformscallsplatform.mac_ver()(re-reads SystemVersion.plist) even when bothversionandarchwere passed, the typical cross-target case. -> PR perf(tags): skip platform.mac_ver() when version and arch are givenΒ #1255Simplifications
pylock.py+direct_url.pyβ ~150 lines duplicated nearly verbatim (_get,_get_required,_get_object, and character-for-character identical error classes). A shared private module would keep them from drifting β the bool-rejection fix above currently needs making twice.tasks/check.pyis dead: it calls PyPI XML-RPC methods removed years ago and depends onpkg_resources/invoke. Removing it also dropstasks/__init__.py,tasks/requirements.txt, thetypes-invokemypy dep, and a ruff per-file-ignore. -> PR chore(tasks): remove dead tasks/check.py and its invoke wiring (closes #827)Β #1275if version >= (11, 0):blocks inmac_platforms(tags.py:652/662); dead py2 guard in_manylinux.py:144;_GLibCVersion(*...)re-wrap at_manylinux.py:240;set(parsed.extras or [])deador [](requirements.py:70); doublestr.lowerpass in_process_dynamic(metadata.py:664); wrong struct-format comments in_elffile.py:60-85(parsing itself verified correct); unused misspelledEMachine.AArc64;expect()docstring says the token is not read but it returnsself.read()(_tokenizer.py:138). -> PR refactor: small cleanups from the #1239 reviewΒ #1281 (the two_manylinux.pyitems are false positives β exercised by tests; see PR)Modernizations
Little to do β slots, cached hashes, and prebound predicates are already in good shape. Two notes:
Token(_tokenizer.py:14) is a slotless dataclass allocated per token;NamedTupleworks on 3.9. -> PR perf: add __slots__ to token classesΒ #1258os.PathLikecomment in_manylinux.py:32is stale now that the floor is 3.9. -> PR fix(_musllinux): pass archs as a list in the __main__ debug blockΒ #1280Docs
Marker.evaluatedocuments:raises UndefinedEnvironmentName:but the code raisesKeyError(tests pinKeyError); the exception class hasn't been raised since 22.0 anddocs/markers.rst:16imports it pointlessly. Decide: either raise it or remove it from docs/__all__. -> PR fix(markers): correct Marker.evaluate :raises: from docs (and subclass KeyError for backcompat)Β #1276Marker.__and__is missing entirely from the rendered HTML andMarker.__or__renders withtype.__or__'s "Return self|value." docstring β autodoc drops docstring-less special members. Add real docstrings (with.. versionadded:: 26.1). -> PR docs(markers): add docstrings to Marker.__and__/__or__ so they render in the API referenceΒ #1274__all__exports invisible in rendered docs for lack of docstrings:specifiers.BaseSpecifier,utils.BuildTag,tags.AppleVersion,tags.PythonVersion. -> PR docs: render __all__ exports BuildTag/BaseSpecifier/AppleVersion/PythonVersion in the API referenceΒ #1277docs/requirements.rst:71documentsRequirement(requirement)but the parameter isrequirement_stringβ the documented keyword call raisesTypeError. -> PR docs(requirements): fix Requirement constructor parameter name in ReferenceΒ #1272parse_wheel_filenamelacks the 26.3versionchangedthatparse_taggot for the same PR (#1234);parse_sdist_filename's:raises:list omits the invalid-version case; METADATA 2.6 (PEP 808) support has no version note. -> PR docs(utils): add 26.3 versionchanged to parse_wheel_filename + complete parse_sdist_filename :raisesΒ #1273-W -n, all spot-checkedversionaddedannotations match git history.What's verified clean: the version.py parse/compare core (differentially fuzzed against 26.2 across ~7k combinations β zero divergences outside the
_rangesbug), the specifier fast paths, ELF parsing, manylinux/musllinux semantics, macOS/iOS/Android tag ordering, URL/extras parsing in_parser.py, and_structures.py/errors.py.The one thing I'd fix before anything else is the
_ranges.pyboundary-ordering bug β it's an unreleased correctness regression inSpecifierSet.contains/filter, exactly the hot path resolvers depend on.