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