The package-content policy

A release publishes two files, a wheel and an sdist. This page states what may be in them, what may never be, and what has to be: the wheel is what lands on sys.path, the sdist is what a build starts from, and both are read before either is published.

.github/scripts/verify_dist_contents.py enforces every rule down to the last section — the dist job of test.yml runs it on a build of every pull request, and the same job, reached through release.yml’s test call, on the files reusable-build.yml signed. The last section is the rules nothing here enforces, which say so one by one rather than leaving the list looking complete.

The script’s constants are those rules, and the paragraph introducing each list below names the ones the list states. tests/verify_dist_contents_test.py compares the two in both directions, so this page cannot state a rule the script does not have, and the script cannot grow a rule this page does not state. The prose around the lists is prose, and is checked by nobody.

What may never be in either file

Checked against every member of both archives, the ones the _data/ amnesty below admits by directory rather than by extension included: FORBIDDEN_SUFFIXES and FORBIDDEN_NAMES.

  • .pth — executed at interpreter startup, before the first import

  • .dll, .dylib, .pyd, .so — a compiled extension, which this project has none of: the secp256k1 bindings are a separate distribution, resolved at install time and never vendored

  • .exe — a program rather than an extension, and refused for the same reason: nothing here is compiled, so nothing here ships a binary

  • .pyc — a compiled module whose source nobody reviewed

  • .egg, .tar.gz, .whl, .zip — an archive inside an archive, that is, a second package installing with the first

  • sitecustomize.py, usercustomize.py — the two names Python imports for their side effects alone, at startup, from the root of site-packages. Both end in .py, which the wheel’s allowlist would otherwise admit under btclib/: the rule is about the name, not about where it happens to be harmless

A __pycache__ directory is refused too, by name rather than by suffix: an empty one is a member in its own right and carries no .pyc.

What the wheel may hold

An allowlist, it being what lands on sys.path: Python source under btclib/, btclib/py.typed, whatever sits in a _data/ directory, and the .dist-info metadata the backend writes. Anything else fails, which is what makes a .pth file, a stray top-level module and a bundled shared object one rule rather than three.

Under btclib-<version>.dist-info/ — WHEEL_METADATA_FILES, beside the licenses/ directory the backend copies LICENSE and AUTHORS.md into:

  • METADATA, RECORD, WHEEL — what uv_build writes for a package configured as this one is

entry_points.txt is deliberately not among them: pyproject.toml declares no [project.scripts], and an entry point is a code path a user runs without importing anything, so one appearing there is a packaging change that has to be read.

What the sdist may hold

A structural check rather than an extension allowlist. [tool.uv.build-backend] includes tests/** and docs/** whole, so no pattern there says what a vendored vector may be, and saying it here would be one more edit per extension while what decides is what git tracks — the comparison check-sdist makes. What this adds is what neither of them can say — that every member sits under the archive’s own root directory, that every member is a regular file or a directory (a tar can carry a symlink, a hardlink or a device node, where a zip cannot), and that no directory holds the metadata of some other distribution.

The directories, SDIST_DIRECTORIES:

  • src — the package, at src/btclib

  • tests — its suite

  • docs — the documentation sources

The files at that root, SDIST_ROOT_SUFFIXES and SDIST_ROOT_NAMES. source-include ships most of them by glob, *.md and the ones beside it, so a file landing at the root is shipped without a packaging decision being made; this is where that decision is recorded:

  • .md, .toml, .yaml, .jsonc — what a reader of an unpacked sdist reads, and the configuration of the tools it names

  • LICENSE — the licence, which project.license-files copies into the wheel as well. COPYRIGHT is not beside it: the notice is in every source file already, and the file is the repository’s (btclib-org/.github#135)

  • PKG-INFO, pyproject.toml.orig — written by the backend while it builds the archive: the metadata PyPI reads off it, and the verbatim copy of pyproject.toml it keeps beside the normalized one

  • uv.lock, .python-version, .secrets.baseline — what makes an unpacked sdist buildable, and its lint gate runnable, which is why [tool.uv.build-backend] names the last two

What has to be in there

The wheel, WHEEL_REQUIRED, beside the metadata files above, which are required as well as allowed:

  • btclib/__init__.py — the archive is what this reads, and a module-root pointing at nothing fails the build instead

  • btclib/py.typed — PEP 561 makes its absence silent: the package installs, imports, and type checks as Any

And the sdist, SDIST_REQUIRED:

  • PKG-INFO — the metadata PyPI reads off the archive

  • pyproject.toml — what makes the archive buildable at all

The two carry one payload. uv build builds the sdist and then the wheel from it, so btclib/ is the same set of files in both; a difference either way is a file that reaches a user installing from source and not one installing the wheel, or the reverse.

check-wheel-contents names the package tree

check-wheel-contents runs with package = ["src/btclib"], in the one job verify_dist_contents.py runs in — test.yml’s dist job, on every pull request and, through release.yml’s test call, on the files a release publishes.

What that flag buys is the one question the tool does not ask without it. Unconfigured, it reads the wheel’s own RECORD and says nothing about the tree the wheel was supposed to carry; with the package named it diffs the wheel’s btclib/ against this checkout’s src/btclib/, file for file and in both directions — W101 for what the wheel is missing, W102 for what it has and the tree does not. Measured on a wheel with btclib/py.typed removed and RECORD edited to match, which installs and imports cleanly: unconfigured, OK; configured, W101: Wheel library is missing files in package tree: btclib/py.typed.

It ran unconfigured until btclib-org/btclib#1200, and the reason recorded in btclib-org/btclib#1160 was redundancy rather than inapplicability: check-sdist, a pre-commit hook gated by the lint workflow, diffs this tree against the sdist, and the completeness check above diffs the sdist against the wheel, so chained they imply checkout == sdist == wheel. The organization standard’s section 12 no longer accepts that trade (btclib-org/.github#51): some other check implying the same diff does not stand in for the flag where the flag applies, because the implication is the same assertion bought with code to maintain, and it moves what the dist job knows about the artifact into a chain that holds only while every link runs — and the two links here sit in different workflows.

No ignore list. Nothing here ships outside btclib/, so neither W003 nor W009 can fire, and a list naming codes that cannot trigger would be a line asserting a fact instead of preventing one. btclib-secp256k1 configures ignore = ["W003", "W009"] because its dynamic wheel ships a shared library beside the package on purpose, which is exactly what those two checks exist to flag.

The rules nothing here enforces

Three rules of the policy diybitcoinhardware/embit publishes beside its own checker are not lists of members, and no list of members can show them. That project does not check them either — its policy says so, in as many words — so what follows each here is what stands in for a check.

  • No install-time execution hook. Three shapes could carry one, and each is refused by a rule above: setup.py is not a root file the sdist ships, entry_points.txt is not one of the wheel’s metadata files, and a .pth carries a forbidden suffix. None of those rules was written for this, which is worth knowing before trimming one to what a build happens to produce: it would take a guard with it. There is no setup.py in the tree at all — [build-system] names uv_build, the configuration is declarative, and the backend reads no build script of any kind.

  • No network access while the package installs. Which follows from the rule above and is not a check of its own: an installed wheel is Python source and data, pip runs none of it, and the three shapes that would run are the three that are refused.

  • No code generated from the network while a release is built. [build-system] requires is the one line that can put somebody else’s code in a build here, and it is one requirement long: tests/build_system_test.py asserts it names uv_build and nothing else, so a build requirement cannot be added in silence. A diff is what reads it; the test is what says a reader has to.

What none of this claims is that a build made no network request. Proving that needs a sandboxed build, which is a different undertaking at a different cost, and naming it as policy is the honest version. What is checkable instead is the result: the build is reproducible from SOURCE_DATE_EPOCH, and RELEASING.md has the command that rebuilds a release from its tag and the bounds on it.