Skip to content

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, that from.version admits. from.version must 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 with newest. Each is a name in symbols of a flag_only change, so the scan reports it. The pypdf pack lists PdfFileMerger, PdfFileReader and PdfFileWriter, which have raised DeprecationError since 3.0.0, and the modules PyPDF2.merger, PyPDF2.pdf and PyPDF2.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).

  1. Code. A finding that is not not_a_usage for a name in lacks, or for one inside it (PyPDF2.utils.PdfReadError): that code cannot run on the newest release, so an older one is in use.
  2. A manifest the scan reads: every *requirement*.txt (dev-requirements.txt too), requirements/*.txt, and, for their declarations only, *requirements*.in and *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-16 pip 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); in pyproject.toml and a Pipfile the 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, and known_third_party under [tool.isort], a Ruff or mypy list, a project's description and a URL do not; in a requirements, input or constraints file, a pip option that can move the release, which is every option but -r or -c to a manifest the scan reads (one that an include names too, ADR-021 D15), -e to a local directory, --extra-index-url and --trusted-host. A hash on a continued line is such an option. A line that is read and names a release (PyPDF2<3 in 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's exclude removed 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).
  3. Resolver settings in pyproject.toml and Pipfile: tool.uv.exclude-newer, resolution, index-url, find-links, no-index or a pip table, a tool.uv.index that is not explicit, 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).
  4. 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.toml and pylock.*.toml, environment.yml and .yaml, pip.conf, uv.toml and requirements*.lock. LOCKS and LOCK_GLOBS in scan/manifests.py are the list. So is a constraints file or a *requirements*.in input 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 -c from 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.
  5. 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 exclude removed included: PyPDF2 <op> <version> that does not admit the newest release or that cannot be read, or PyPDF2-<version> of another release (a Dockerfile, a CI file, a tox.ini, a notebook's !pip install, a committed environment's dist-info). So is a name in lacks written 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, so from PyPDF2 import utils with nothing that uses the name is not read. A UTF-16 file is read after its byte-order mark. A file over max_file_bytes is 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.
  6. What git ignores, found with scandir and never read: a lock, a requirements file, a constraints file or a *requirements*.in beside the repository root or a manifest that declares the distribution, and a .venv, venv or env in the root or beside any listed file, found with one lstat per name, whose site-packages holds a dist-info or egg-info of 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 refuses newest: 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<1 and openai>=0.20 do. openai==0.18.0, openai<0.27 and openai>=0.10,<0.27 admit 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.contrary finds 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 in from.version, and whether a requirement written anywhere admits some release in it. openai==0.28.1 in a Dockerfile, and an openai-0.28.1.dist-info in a committed environment, are no evidence. openai>=1, openai==0.18.0 and openai-1.45.0.dist-info are, 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 --verify finds 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 over max_file_bytes up to 32 MiB of it in windows. Elsewhere the walk still opens nothing it did not.
  • run.json gains packs[].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.since is a gate too: ScanSpec.legacy_since and legacy_range are left out of spec_digest like legacy_newest. The openai pack is 0.3.0 for since and introduces.
  • 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 Pipfile or setup.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 a pip.conf in 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 of pypdf that admits no release at or above to.version (pypdf==3.0.0, pypdf<6), in any manifest the scan reads, blocks as new_version_unsupported and names its path (ADR-050 D5): before, the same repository was rewritten to pypdf>=6.19 in one file and left a pin that cannot resolve in another.