From 1f0d06413f1b60b9d5daa62e5026a956d4382970 Mon Sep 17 00:00:00 2001 From: Bernat Gabor Date: Sat, 19 Sep 2026 00:35:57 -0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(build):=20publish=20a=20third-?= =?UTF-8?q?party=20notices=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit virtualenv already satisfies its MIT obligations: every embedded pip and setuptools wheel carries its own MIT notice inside its own .dist-info/licenses/ directory, redistributed unmodified. What was missing was discoverability, not compliance - nothing at the top level told a downstream auditor those notices existed or where to find them without unzipping a bundled wheel to check. tasks/upgrade_wheels.py now writes THIRD-PARTY-NOTICES.md alongside embed/__init__.py, reading the same BUNDLE_SHA256 table so a wheel bump keeps it current with no separate update step. Only the licence of the wheel virtualenv itself redistributes is reproduced; whatever that wheel in turn vendors already carries its own licence inside it and stays there rather than being duplicated at the top level. Identical licence text across bundled versions of the same package collapses into one section listing every matching filename. LICENSE itself is untouched. Appending third-party text to it is what breaks GitHub's own licence detection on repositories that try it, so the notices live in a separate file instead and are declared alongside LICENSE through PEP 639 license-files rather than folded into it. The regression test locates THIRD-PARTY-NOTICES.md relative to its own file rather than through BUNDLE_FOLDER: a non-editable install puts the installed package under site-packages with no relationship to the checkout, so deriving the repo root from the installed package's own runtime path only worked by coincidence in an editable dev environment and failed outright under CI's real install. --- THIRD-PARTY-NOTICES.md | 62 ++++++++++++++++++++++++++ pyproject.toml | 4 ++ tasks/upgrade_wheels.py | 63 +++++++++++++++++++++++++++ tests/unit/seed/wheels/test_bundle.py | 10 +++++ 4 files changed, 139 insertions(+) create mode 100644 THIRD-PARTY-NOTICES.md diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md new file mode 100644 index 000000000..b50c4f479 --- /dev/null +++ b/THIRD-PARTY-NOTICES.md @@ -0,0 +1,62 @@ + + +# Third-party notices + +virtualenv is distributed under the MIT License, see `LICENSE`. The wheels under `src/virtualenv/seed/wheels/embed/` are +redistributed unmodified and remain under their own licenses, reproduced below. Each wheel also carries its own license +text inside its `.dist-info/licenses/` directory, including the licenses of anything it in turn vendors. + +## pip + +Bundled as: `pip-26.0.1-py3-none-any.whl`, `pip-26.2.1-py3-none-any.whl` + +Upstream: https://pypi.org/project/pip/ + +``` +Copyright (c) 2008-present The pip developers (see AUTHORS.txt file) + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +``` + +## setuptools + +Bundled as: `setuptools-82.0.1-py3-none-any.whl`, `setuptools-84.0.0-py3-none-any.whl` + +Upstream: https://pypi.org/project/setuptools/ + +``` +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. +``` diff --git a/pyproject.toml b/pyproject.toml index ff34d9e67..49df37b2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,10 @@ keywords = [ "virtual", ] license = "MIT" +license-files = [ + "LICENSE", + "THIRD-PARTY-NOTICES.md", +] maintainers = [ { name = "Bernat Gabor", email = "gaborjbernat@gmail.com" }, { name = "Rahul Devikar", email = "rahuldevikar5512@gmail.com" }, diff --git a/tasks/upgrade_wheels.py b/tasks/upgrade_wheels.py index edb445416..5ee3c4ee5 100644 --- a/tasks/upgrade_wheels.py +++ b/tasks/upgrade_wheels.py @@ -8,6 +8,7 @@ import shutil import subprocess import sys +import zipfile from collections import OrderedDict, defaultdict from pathlib import Path from tempfile import TemporaryDirectory @@ -20,11 +21,13 @@ BUNDLED = ["pip", "setuptools"] SUPPORT = [(3, i) for i in range(9, 17)] DEST = Path(__file__).resolve().parents[1] / "src" / "virtualenv" / "seed" / "wheels" / "embed" +NOTICES_DEST = Path(__file__).resolve().parents[1] / "THIRD-PARTY-NOTICES.md" def run() -> NoReturn: if "--regen" in sys.argv[1:]: render_init() + render_notices() raise SystemExit(0) old_batch = {i.name for i in DEST.iterdir() if i.suffix == ".whl"} with TemporaryDirectory() as temp: @@ -39,6 +42,7 @@ def run() -> NoReturn: print(f"Outcome {outcome} added {added} removed {removed}") # ruff:ignore[print] _write_changelog(added, removed) render_init(folders=folders) + render_notices() raise SystemExit(outcome) @@ -218,6 +222,65 @@ def _hash_bundled_wheel(path: Path) -> str: subprocess.run([sys.executable, "-m", "ruff", "format", str(dest_target), "--preview"], check=False) +def render_notices() -> None: + """Write ``THIRD-PARTY-NOTICES.md`` from the wheels currently bundled in DEST. + + Reads the ``BUNDLE_SHA256`` table ``render_init`` just wrote, so the notices always match what is actually bundled. + Only the license of the wheel virtualenv itself redistributes is reproduced here - whatever that wheel in turn + vendors (pip's own ``_vendor`` tree, for example) already carries its license inside the wheel and stays there + rather than being duplicated at the top level. + + """ + tree = ast.parse((DEST / "__init__.py").read_text(encoding="utf-8")) + wheel_names: list[str] = [] + for node in tree.body: + if isinstance(node, ast.Assign) and any( + isinstance(target, ast.Name) and target.id == "BUNDLE_SHA256" for target in node.targets + ): + wheel_names = sorted(ast.literal_eval(node.value)) + break + groups: dict[tuple[str, str], list[str]] = defaultdict(list) + for wheel_name in wheel_names: + distribution = wheel_name.split("-")[0] + groups[distribution, _wheel_license_text(DEST / wheel_name)].append(wheel_name) + sections = [] + for (distribution, text), names in sorted(groups.items()): + filenames = ", ".join(f"``{name}``" for name in sorted(names)) + sections.append( + f"## {distribution}\n\nBundled as: {filenames}\n\n" + f"Upstream: https://pypi.org/project/{distribution}/\n\n" + f"```\n{text.strip()}\n```\n" + ) + header = dedent("""\ + + # Third-party notices + + virtualenv is distributed under the MIT License, see `LICENSE`. The wheels under + `src/virtualenv/seed/wheels/embed/` are redistributed unmodified and remain under their own licenses, + reproduced below. Each wheel also carries its own license text inside its `.dist-info/licenses/` + directory, including the licenses of anything it in turn vendors. + + """) + NOTICES_DEST.write_text(header + "\n".join(sections), encoding="utf-8") + + +def _wheel_license_text(path: Path) -> str: + with zipfile.ZipFile(path) as archive: + names = archive.namelist() + dist_info = next(name.split("/", 1)[0] for name in names if name.endswith(".dist-info/METADATA")) + prefix = f"{dist_info}/licenses/" + # top-level files only: a nested match would be a license belonging to something the wheel itself vendors + candidates = sorted( + name + for name in names + if name.startswith(prefix) and "/" not in name[len(prefix) :] and "LICENSE" in Path(name).name.upper() + ) + if not candidates: + msg = f"no top-level license file found under {prefix} in {path.name}" + raise RuntimeError(msg) + return archive.read(candidates[0]).decode("utf-8") + + def _support_table_from_existing_init() -> OrderedDict[str, OrderedDict[str, str]]: source = (DEST / "__init__.py").read_text(encoding="utf-8") tree = ast.parse(source) diff --git a/tests/unit/seed/wheels/test_bundle.py b/tests/unit/seed/wheels/test_bundle.py index 487d827c2..41dd7b9d0 100644 --- a/tests/unit/seed/wheels/test_bundle.py +++ b/tests/unit/seed/wheels/test_bundle.py @@ -95,6 +95,16 @@ def test_every_wheel_on_disk_has_sha256() -> None: assert on_disk == BUNDLE_SHA256.keys() +def test_notices_mention_every_bundled_distribution() -> None: + # rooted at this test file rather than BUNDLE_FOLDER: a non-editable install puts the installed package + # under site-packages with no relationship to the checkout, but tests/ always runs from the repo itself + repo_root = Path(__file__).resolve().parents[4] + notices = (repo_root / "THIRD-PARTY-NOTICES.md").read_text(encoding="utf-8") + distributions = {wheel_name.split("-")[0] for wheel_name in BUNDLE_SHA256} + missing = {name for name in distributions if f"## {name}" not in notices} + assert not missing, f"THIRD-PARTY-NOTICES.md is missing a section for: {sorted(missing)}" + + def test_get_embed_wheel_verifies_pip(for_py_version: str) -> None: wheel = get_embed_wheel("pip", for_py_version) assert wheel is not None