ADR-056: The newest legacy release¶
Status¶
Accepted.
Decision¶
A pack may name the newest release of the distribution it migrates from. A declaration that names no
version, or one whose range reaches below the floor but admits that release, is then taken as that
release, unless the repository shows another one. This widens the legacy version gate of ADR-050 D5 for the one pack that names it,
py-pdf/pypdf2-to-pypdf, and for nothing else. A pack whose newest release is on the new side
cannot name one, and D5 gives it the other way in: code that uses a name only the old range defines.
The gate stops a repository that declares the legacy distribution with no version, or with no lower
bound, because the pack's rules were measured above the floor. For PyPDF2 that stopped the
repositories whose install is exactly the release the rules were measured on: PyPI's last PyPDF2
release is 3.0.1 (2022-12-31), and a clean pip install PyPDF2 resolves to it. It was the largest
single stop for the pack in the benchmark frame (BENCHMARK_RESULTS.md).
D1. Two optional fields on from¶
newest: a final release (a post release is allowed; a pre-release, a dev release and a local version are not), spelled as PEP 440 normalises it, thatfrom.versionadmits.from.versionmust have a floor: with none, no release is out of range and the field would say nothing.lacks: the qualified names that release no longer defines, sorted and de-duplicated, and only withnewest. Each is a name insymbolsof aflag_onlychange, so the scan reports it. The pypdf pack listsPdfFileMerger,PdfFileReaderandPdfFileWriter, which have raisedDeprecationErrorsince 3.0.0, and the modulesPyPDF2.merger,PyPDF2.pdfandPyPDF2.utils, which only the 1.x releases ship.
to has neither: the schema refuses an unknown key there. Both reach the scan as
ScanSpec.legacy_newest and legacy_lacks, which are gates like legacy_floor, so spec_digest
does not move and a Gate 1 measurement stays bound to the spec it was taken on.
D2. A declaration that installs the newest release passes, unless the repository says otherwise¶
The gate still asks every declaration of the legacy distribution, one at a time. One that fails the
floor passes now when it installs the newest release: a requirement the edit can rewrite as a whole
(no extras, no URL, no hash, on one line, not editable), with no environment marker that may switch
it off, that no part of which allows a pre-release, and some alternative of which contains
newest. PyPDF2, PyPDF2>=2.10, PyPDF2<4 and PyPDF2===3.0.1 install it; PyPDF2<3,
PyPDF2~=2.12, PyPDF2!=3.0.1, PyPDF2>=3.0.0rc1, a requirement with a marker, a hash-pinned
one, -e git+...#egg=PyPDF2, a wheel's file name and one the scan cannot read do not.
Only then is the repository asked whether anything else decides the release
(scan/installed.py, contrary). The first path that says it does is the evidence, and the run is
blocked as before, naming that path. The checks run in this order, and each fails closed (a manifest
it cannot read is evidence), except that a listed file that cannot be read, or is over 32 MiB, is not
searched for a version or a name. A file over 32 MiB is named as a file_too_large limitation when
the run took a release (installed.unsearched).
- Code. A finding that is not
not_a_usagefor a name inlacks, or for one inside it (PyPDF2.utils.PdfReadError): that code cannot run on the newest release, so an older one is in use. - A manifest the scan reads: every
*requirement*.txt(dev-requirements.txttoo),requirements/*.txt, and, for their declarations only,*requirements*.inand*constraints*.txt, which no rule writes (their declarations carry no pin, and a bare name in one is not read). One that cannot be read whole, or holds a NUL byte (a UTF-16pip freeze); one with a line that names the distribution where no declaration was read, once comments are cut (a bare name in an input or constraints file); inpyproject.tomland aPipfilethe names are counted instead, against the rows read: a key or a string where a dependency can be declared (PEP 621's and 735's, the tables of uv, Poetry, PDM, Hatch, Pixi and Rye, and every table of a Pipfile but[source],[requires],[scripts]and[pipenv], a category of its own such as[docs]among them), so a[tool.uv.sources]key, a constraint or an override counts, andknown_third_partyunder[tool.isort], a Ruff or mypy list, a project'sdescriptionand a URL do not; in a requirements, input or constraints file, a pip option that can move the release, which is every option but-ror-cto a manifest the scan reads (one that an include names too, ADR-021 D15),-eto a local directory,--extra-index-urland--trusted-host. A hash on a continued line is such an option. A line that is read and names a release (PyPDF2<3in a constraints file) is judged like any declaration, by the floor and by item 5; a file that names nothing about the distribution is no evidence for being there. A manifest the user'sexcluderemoved is judged by its pip options and, in item 3, its resolver settings, and by nothing else: no row is read from it, and a version it names is judged by item 5 like any listed file. A path no report can spell, a link or a file the path guard refuses is skipped, and so is one that cannot be read (item 4 stops at such a constraints file or input, and at a name no report can spell). - Resolver settings in
pyproject.tomlandPipfile:tool.uv.exclude-newer,resolution,index-url,find-links,no-indexor apiptable, atool.uv.indexthat is notexplicit,tool.poetry.source,tool.pdm.source, a Pipfile[[source]]that is not PyPI, or a TOML file this Python cannot parse (nested too deep for the parser included). - A file git lists that can pin or constrain a release without being a manifest the scan reads:
poetry.lock,Pipfile.lock,uv.lock,pdm.lock,pixi.lock,conda-lock.yml,pylock.tomlandpylock.*.toml,environment.ymland.yaml,pip.conf,uv.tomlandrequirements*.lock.LOCKSandLOCK_GLOBSinscan/manifests.pyare the list. So is a constraints file or a*requirements*.ininput that no manifest was read from: a link, or one in a directory the scan leaves out (a dot-directory,vendor/,build/,dist/), where a-cfrom anywhere can still reach it. So is a file name no report can spell (a control, format or backslash character), which the walk skips: what is under it is unseen. - A version written anywhere else, in the name or the bytes of any file git lists, a dot-directory
and a file the user's
excluderemoved included:PyPDF2 <op> <version>that does not admit the newest release or that cannot be read, orPyPDF2-<version>of another release (a Dockerfile, a CI file, atox.ini, a notebook's!pip install, a committed environment'sdist-info). So is a name inlackswritten there, which finds what item 1 cannot: a notebook, a Python 2 file, a stub, code the user excluded, and prose. A class is read by its own name (PdfFileReader) and a module by its dotted path (PyPDF2.utils), in the case it is written, sofrom PyPDF2 import utilswith nothing that uses the name is not read. A UTF-16 file is read after its byte-order mark. A file overmax_file_bytesis searched too, in windows of 1 MiB that repeat the last 4 KiB of the one before, so that no more than a window is held and a mention is whole in one of them; a UTF-16 one is read as bytes, and a file over 32 MiB is not searched. A mention is read by the window it starts in, where it lies whole, so an edge never cuts one into another release. - What git ignores, found with
scandirand never read: a lock, a requirements file, a constraints file or a*requirements*.inbeside the repository root or a manifest that declares the distribution, and a.venv,venvorenvin the root or beside any listed file, found with onelstatper name, whosesite-packagesholds adist-infooregg-infoof another release.
Nothing is taken from a directory that is not the top of a git repository: the fallback walk skips
dot-directories, so a pin in .github/workflows would never be seen. A declaration that would have
installed the newest release is then blocked as an unpinned one is, and its message says that
obelize lists every file only at the top of a git repository, so that the way to lift the block is to
run it there, with the pin the other (runtime.Blocked.unlisted).
When nothing is found, the run says what it took: Scan.assumed is the (distribution, release)
pair, packs[].assumed in run.json is the release, and the terminal and REPORT.md say that the
distribution is declared with no version that rules the release out and is taken as it. A blocked
run's message names the evidence and still says what to pin, and that a pin moves the release and
not the code.
D3. One consumer is enough¶
ADR-050 widens the format only for a field a second, real migration needs. That migration is PyPDF2
(ADR-051), and newest is for it: it is the only pack whose newest release is inside the range its
rules were measured on. openai's newest release is outside >=0.27.0,<1, and the Gemini pack's
from has no floor, so neither names a newest: a pack that cannot name an index's last release
gets no inference from it, which is the right default. D5 is what openai fills instead.
D4. Checked weekly¶
newest is a claim about an index that keeps changing. The weekly end-to-end run reads PyPI's
release list for PyPDF2 and fails when a final release that is not yanked is newer than the pack's
from.newest, or when the list no longer holds newest. A pack whose claim has lapsed would take a
declaration that installs something else for the release the rules were measured on. The step reads
the number from the pack, and a test holds the set of packs that name a newest to the one the step
checks, so a second pack cannot name one without a check.
D5. Code that uses a name only the old range defines¶
from.since lists qualified names that no release below the floor of from.version defines, and
that the new releases dropped. openai.ChatCompletion and openai.Audio first exist in 0.27.0
(0.26.5 lacks both, and 0.27.0 and 0.28.1 define them), and 1.0 turned them into stubs that raise,
so code that calls one was written for a release in >=0.27.0,<1, whichever release a bare
openai would install today. The scan takes the range for the declaration, as D2 takes the newest
release, when all of these hold:
- the pack names
since, which needs a floor and refusesnewest: one pack stands for a declaration one way, so a run never takes two things; - some finding that is not a manifest row or a mention uses a name in
since, or one under it (openai.ChatCompletion.create); - a legacy declaration is below the floor, is a plain requirement by D2's test (no extras, URL,
hash or marker, no pre-release), and admits some release of
from.version.openai,openai<1andopenai>=0.20do.openai==0.18.0,openai<0.27andopenai>=0.10,<0.27admit none, and stay blocked, as does an undeclared distribution: a code-only rewrite would leave an unseen pin beside new-API code; - the repository is the top of a git repository, as D2 requires;
installed.contraryfinds nothing, by D2's six checks with one change: where it asks whether a written release is the one taken, it asks whether the release is infrom.version, and whether a requirement written anywhere admits some release in it.openai==0.28.1in a Dockerfile, and anopenai-0.28.1.dist-infoin a committed environment, are no evidence.openai>=1,openai==0.18.0andopenai-1.45.0.dist-infoare, and so is a requirement nobody can read.
The run says what it took: Scan.assumed holds the distribution and from.version where it holds
a release for D2, packs[].assumed in run.json is that range, and the terminal and REPORT.md
say that the declaration names no version that rules a release in it out and that the code uses a
name only those releases define. A block that D2's evidence causes says which range it would have
taken (Blocked.taken).
since is the pack's claim about releases that are all published and cannot change, so no weekly
check reads an index for it. The measurement is kept in the pack's SOURCES.md and, for the old
release, in tests/packs/openai_legacy_check.py.
D6. A pin that admits only the old releases, beside code that needs the new one¶
Where one distribution holds both APIs, a legacy pin over code that never imports it could not be
told from a pin the code still runs on (ADR-021 D4), so it was context only and a run over
openai==0.28.1 beside from openai import OpenAI said "No pack applies", which reads as healthy.
to.introduces lists qualified names no release of from.version defines (openai.OpenAI,
openai.AsyncOpenAI, openai.AzureOpenAI: 0.26.5, 0.27.0 and 0.28.1 measured). A file that uses
one, as written or through an alias of the module (import openai as oa; oa.OpenAI), found from its
imports and the attributes of what they bind and not from a name alone, makes a legacy declaration
that admits some release of from.version and none above it (openai==0.28.1, openai<1) a
manifest_code_mismatch, a row left for review, when nothing migrated and nothing holds the pin. A
declaration that admits a release of the new API (openai, openai>=0.27), one nobody can read and
one the code still migrates around are not judged this way. The schema allows the field only for a
pack whose two sides are one distribution and whose module is shared, and refuses a name that
match.symbols lists as legacy or that no module of match.imports holds. ScanSpec.new_names
carries it, and spec_digest includes it once set, since it adds rows.
Consequences¶
- A repository that declares PyPDF2 with no version, or with a range that reaches below 3 but admits
3.0.1 (
PyPDF2>=2.10), can now be migrated, and a model the user configured can then be asked about its rows, so source reaches the endpoint that the earlier stop kept it from. The run says what it assumed, and--verifyfinds what the names do not. - The gate reads more of a repository on this path: every listed file, once, and only when some
declaration passes through
newest, a file overmax_file_bytesup to 32 MiB of it in windows. Elsewhere the walk still opens nothing it did not. run.jsongainspacks[].assumed; the formats stay unstable through 0.x (ADR-048). The pypdf pack is 0.3.0: a pack field it now fills is a minor bump.from.sinceis a gate too:ScanSpec.legacy_sinceandlegacy_rangeare left out ofspec_digestlikelegacy_newest. The openai pack is 0.3.0 forsinceandintroduces.- The inference is a claim about an install nobody ran. It cannot see the environment outside the
repository, a release another package requires, a submodule's files, a gitignored file the last
check does not look for, a Python declared only in a
Pipfileorsetup.py, or an index the machine rewrites. KNOWN_ISSUES.md lists them. - The installer's settings outside the repository are not read, on purpose: not a
PIP_*variable (PIP_INDEX_URL,PIP_CONSTRAINT,PIP_FIND_LINKS,PIP_NO_INDEX), not apip.confin the home directory. The machine that scans is not the one that installs (in CI it is another), and reading its state would make a finding depend on it, where the scan is deterministic (tests/oracle/test_scan_determinism.py). A repository that depends on such a setting pins the release in a manifest the scan reads. - The holes the gate had are closed. A requirement line PEP 508 refuses (
PyPDF2~=1) is a row for its name with no version, and a local build (==3.0.1+corp) names no floor, since it is not the release the rules were measured on: both block like a pin nobody can read, whatever another declaration says. A pin ofpypdfthat admits no release at or aboveto.version(pypdf==3.0.0,pypdf<6), in any manifest the scan reads, blocks asnew_version_unsupportedand names its path (ADR-050 D5): before, the same repository was rewritten topypdf>=6.19in one file and left a pin that cannot resolve in another.