feat(pypirsf): read dependency data from an RSF file - #4
Merged
Conversation
Adds pypirsf.File: open a local RSF, index every package by canonical name in one pass, then look up any package's dependency metadata by offset. VALIDATED AGAINST THE REAL PRODUCTION FILE (932,861 records, 936 MB decompressed): full scan + index 256 ms dictionary 4,096 names per-package lookup 31-703 us (flask 66us; boto3, 2,086 versions, 703us) and the decoded content is unmistakably real: flask 3.1.3 -> ">=3.9" with asgiref>=3.2 ; extra == "async", blinker>=1.9.0, click>=8.1.3; django 6.1rc1 -> argon2-cffi>=23.1.0; extra == "argon2"; boto3 -> botocore>=1.12.99,<1.13.0. A desynchronized reader produces garbled strings, not correct requirement specifiers, so asserting on recognizable content is what makes this meaningful. The design decision that matters: this reader NEVER traverses the snapshots array. Every hazard in this format lives in there -- three historical subfield layouts that must be detected from the file's own schema, and a per-element index block whose width cannot be taken from the schema because production files use the v1 index format and report the array as un-indexed even though the block is physically present. AdvanceTo skips the array wholesale by its own size header, which the format supports safely, so none of that applies. Version sets therefore come from the deps blob's own keys rather than from the snapshot timeline. That is not a shortcut: the set of versions with CAPTURED dependencies is exactly the set a resolver can safely use, because a version whose dependencies are unknown cannot be resolved through anyway. The cost is that File reports captured versions rather than every version that ever existed, which is documented. Concurrency is by construction rather than by lock. Each lookup gets its own io.SectionReader and its own rsf.Reader, sharing only the immutable schema. The alternative -- seeking one shared reader -- requires resetting the buffered reader in lockstep with every seek, and getting that wrong reads stale bytes from the previous position instead of failing. Also records two facts learned the hard way. The rsf struct tags drive the WRITER only; there is no tag-driven Unmarshal, so record.go documents the layout and lets tests generate real files rather than functioning as a reader. And WriteObject reflects over a value, not a pointer -- passing &record reports "unknown field type 0x16" (reflect.Pointer) instead of dereferencing. Tests: synthetic fixtures are written with the real writer over these same structs, so record framing, the snapshots array header, and the trailing additive fields come from the code that produces production files rather than from this test's idea of the format. Coverage includes a record after the first (the dictionary lives on record 0 and is read differently, so a reader can be correct there and wrong everywhere else), an empty deps field, ErrPackageNotFound, a pre-cutover file with no deps fields in its schema reported at Open, and concurrent lookups under -race. TestRealRSF is opt-in via PYPIRSF_TEST_FILE since the file is ~1 GB. Verified: 82.6% statement coverage; -race clean; gofmt silent; golangci-lint v2.11.2 reports 0 issues from the module root. Refs rstudio/package-manager#18647 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
pypirsf.File— open a local RSF, index every package by canonical name in one pass, then look up any package's dependency metadata by offset.This is what makes the standalone resolver possible: point it at an RSF you already have, no network.
Validated against the real production file
Not just synthetic fixtures. Against the live 932,861-record, 936 MB decompressed PyPI RSF:
And the decoded content is unmistakably real:
flask3.1.3 →>=3.9, withasgiref>=3.2 ; extra == "async",blinker>=1.9.0,click>=8.1.3django6.1rc1 →argon2-cffi>=23.1.0; extra == "argon2",asgiref>=3.9.1boto3→botocore>=1.12.99,<1.13.0That last point is the reason the assertions check recognizable content rather than just a nil error: a desynchronized reader of a binary format produces garbled strings, not correct requirement specifiers.
Relevant to #18651's
<100ms cold / <1ms warmgate: the 256 ms is a one-time load, and per-package access is microseconds.The design decision that matters
This reader never traverses the
snapshotsarray. Every hazard in the format lives in there:AdvanceToskips the array wholesale by its own size header — which the format explicitly supports — so none of that applies.Version sets therefore come from the deps blob's own keys, not the snapshot timeline. That isn't a shortcut: the set of versions with captured dependencies is exactly the set a resolver can safely use, since a version whose dependencies are unknown can't be resolved through anyway. The documented cost is that
Filereports captured versions rather than every version that ever existed.Concurrency by construction, not by lock
Each lookup gets its own
io.SectionReaderand its ownrsf.Reader, sharing only the immutable schema. The alternative — seeking one shared reader — requires resetting the buffered reader in lockstep with every seek, and getting that wrong reads stale bytes from the previous position instead of failing. Covered by a-racetest.Two facts learned the hard way, now recorded in the code
rsfstruct tags drive the writer, not the reader. There is no tag-drivenUnmarshal; reading is manual field navigation against the per-file schema. Sorecord.godocuments the layout and lets tests generate real files — it is not itself a reader. I added a comment saying so, since the previous PR could be read as implying otherwise.WriteObjectreflects over a value, not a pointer. Passing&recordreportsunknown field type 0x16(reflect.Pointer) rather than dereferencing.Testing
Synthetic fixtures are written with the real writer over these same structs, so record framing, the snapshots array header, and the trailing additive fields come from the code that produces production files rather than from this test's idea of the format.
Covered: a record after the first (the dictionary lives on record 0 and is read differently, so a reader can be correct there and wrong everywhere else), an empty deps field,
ErrPackageNotFound, a pre-cutover file whose schema has no deps fields reported atOpen, and concurrent lookups under-race.TestRealRSFis opt-in viaPYPIRSF_TEST_FILEsince the file is ~1 GB and can't be committed. Its doc comment records how to fetch one, and notes those files are licensed for Package Manager customers.82.6% statement coverage;
-race -count=1clean;gofmt -l .silent;golangci-lint run ./...at CI's pinned v2.11.2 → 0 issues from the module root.Next
The
MetadataIndeximplementation over this, then the CLI.🤖 Generated with Claude Code