├── .flake8 ├── .git-blame-ignore-revs ├── .github └── workflows │ ├── lint.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── __build__.py ├── docs ├── conf.py ├── examples │ ├── build_pep517_example.py │ ├── buildscript.rst │ ├── pep-517-builder.rst │ └── pep_517_builder.py ├── index.rst └── requirements.txt ├── pyproject.toml ├── requirements-dev.txt ├── requirements.txt ├── tests ├── __init__.py ├── conftest.py ├── test_corrupted_metadata.py ├── test_lazy_mode.py ├── test_metas.py ├── test_resolved.py ├── test_wheel_gen.py ├── test_wheelfile.py ├── test_wheelfile_cloning.py ├── test_wheelfile_readmode.py └── test_wheels_with_vendoring.py └── wheelfile.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 80 3 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Black & isort 2 | 0eeb95448c4eb82b5e357c24bbc02775a7ebfc62 3 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: pre-commit 3 | 4 | on: 5 | pull_request: 6 | push: 7 | branches: [master] 8 | 9 | jobs: 10 | pre-commit: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions/setup-python@v2 15 | - uses: pre-commit/action@v2.0.2 16 | with: 17 | extra_args: --all-files 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tests 3 | 4 | on: [push] 5 | 6 | jobs: 7 | test-ubuntu: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ["3.9", "3.10", "3.11"] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | python -m pip install -r requirements-dev.txt 23 | python -m pip install -r requirements.txt 24 | - name: Test with pytest 25 | run: | 26 | pytest 27 | test-windows: 28 | runs-on: windows-latest 29 | strategy: 30 | matrix: 31 | python-version: ["3.9", "3.10", "3.11"] 32 | 33 | steps: 34 | - uses: actions/checkout@v2 35 | - name: Set up Python ${{ matrix.python-version }} 36 | uses: actions/setup-python@v2 37 | with: 38 | python-version: ${{ matrix.python-version }} 39 | - name: Install dependencies 40 | run: | 41 | python -m pip install --upgrade pip 42 | python -m pip install -r requirements-dev.txt 43 | python -m pip install -r requirements.txt 44 | - name: Test with pytest 45 | run: | 46 | pytest 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Documentation 2 | docs-out 3 | docs_out 4 | 5 | # Built packages 6 | *.tar.gz 7 | *.whl 8 | 9 | # CTags 10 | tags 11 | 12 | # Vim 13 | # Swap 14 | [._]*.s[a-v][a-z] 15 | !*.svg # comment out if you don't need vector files 16 | [._]*.sw[a-p] 17 | [._]s[a-rt-v][a-z] 18 | [._]ss[a-gi-z] 19 | [._]sw[a-p] 20 | 21 | # Session 22 | Session.vim 23 | Sessionx.vim 24 | 25 | # Persistent undo 26 | [._]*.un~ 27 | 28 | # Temporary 29 | .netrwhist 30 | *~ 31 | # Auto-generated tag files 32 | tags 33 | # Persistent undo 34 | [._]*.un~ 35 | 36 | # Documentation output directory 37 | doc-out/ 38 | 39 | # Created by .ignore support plugin (hsz.mobi) 40 | ### Example user template template 41 | ### Example user template 42 | 43 | # IntelliJ project files 44 | .idea 45 | .idea/* 46 | *.iml 47 | out 48 | gen### Python template 49 | # Byte-compiled / optimized / DLL files 50 | __pycache__/ 51 | *.py[cod] 52 | *$py.class 53 | 54 | # C extensions 55 | *.so 56 | 57 | # Distribution / packaging 58 | .Python 59 | env/ 60 | build/ 61 | develop-eggs/ 62 | dist/ 63 | downloads/ 64 | eggs/ 65 | .eggs/ 66 | lib/ 67 | lib64/ 68 | parts/ 69 | sdist/ 70 | var/ 71 | wheels/ 72 | *.egg-info/ 73 | .installed.cfg 74 | *.egg 75 | 76 | # PyInstaller 77 | # Usually these files are written by a python script from a template 78 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 79 | *.manifest 80 | *.spec 81 | 82 | # Installer logs 83 | pip-log.txt 84 | pip-delete-this-directory.txt 85 | 86 | # Unit test / coverage reports 87 | htmlcov/ 88 | .tox/ 89 | .coverage 90 | .coverage.* 91 | .cache 92 | nosetests.xml 93 | coverage.xml 94 | *,cover 95 | .hypothesis/ 96 | 97 | # Translations 98 | *.mo 99 | *.pot 100 | 101 | # Django stuff: 102 | *.log 103 | local_settings.py 104 | 105 | # Flask stuff: 106 | instance/ 107 | .webassets-cache 108 | 109 | # Scrapy stuff: 110 | .scrapy 111 | 112 | # Sphinx documentation 113 | docs/_build/ 114 | 115 | # PyBuilder 116 | target/ 117 | 118 | # Jupyter Notebook 119 | .ipynb_checkpoints 120 | 121 | # pyenv 122 | .python-version 123 | 124 | # celery beat schedule file 125 | celerybeat-schedule 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # dotenv 131 | .env 132 | 133 | # virtualenv 134 | .venv 135 | venv/ 136 | ENV/ 137 | 138 | # Spyder project settings 139 | .spyderproject 140 | 141 | # Rope project settings 142 | .ropeproject 143 | 144 | # pytest 145 | .pytest_cache/ 146 | 147 | # mypy 148 | .mypy_cache/ 149 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.6.0 4 | hooks: 5 | - id: check-ast 6 | - id: check-yaml 7 | - id: end-of-file-fixer 8 | - id: trailing-whitespace 9 | - id: debug-statements 10 | - id: check-case-conflict 11 | - id: check-merge-conflict 12 | - id: check-symlinks 13 | - id: debug-statements 14 | - repo: https://github.com/pre-commit/mirrors-mypy 15 | rev: 'v1.10.1' 16 | hooks: 17 | - id: mypy 18 | - repo: https://github.com/astral-sh/ruff-pre-commit 19 | rev: v0.5.2 20 | hooks: 21 | - id: ruff 22 | - id: ruff-format 23 | - repo: https://github.com/asottile/yesqa 24 | rev: v1.5.0 25 | hooks: 26 | - id: yesqa 27 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/latest/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/conf.py 11 | 12 | # Optionally set the version of Python and requirements required to build your docs 13 | python: 14 | version: "3.9" 15 | install: 16 | - requirements: docs/requirements.txt 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 5 | 6 | This project adheres to [Semantic 7 | Versioning](https://semver.org/spec/v2.0.0.html). 8 | 9 | ## [0.0.9] - 2024-07-19 10 | ### Changed 11 | - **Dropped support of Python versions lower than Python 3.9.** 12 | - The `WheelFile.writestr_*` methods will now preserve as `ZipInfo` attributes, 13 | if a `ZipInfo` object has been passed instead of the filename. 14 | - `WheelFile.from_wheelfile` constructor will now preserve `ZipInfo` 15 | attributes of the files from distinfo and data directories of the original 16 | archive. This includes file permissions. 17 | - Unconstrained `packaging` requirement. At the time of writing this, all 18 | versions up to `packaging==24.1` pass the tests on Linux. 19 | 20 | ### Fixed 21 | - Lazy mode will no longer confuse corrupted wheeldata with metadata - if 22 | `WHEEL` file is corrupted or wrong, the `.wheeldata` will be set to `None`, 23 | as opposed to `.metadata` as was the case previously. Thanks to 24 | [mboisson](https://github.com/mboisson) for spotting this and the fix. 25 | - Writing a `.dist-info/RECORD` file in a subdirectory of the archive will no 26 | longer trigger an `AssertionError`. This should help with vendoring packages 27 | inside wheels. Thanks to [mboisson](https://github.com/mboisson) for 28 | providing a fix. 29 | 30 | ## [0.0.8] - 2021-08-03 31 | ### Changed 32 | - Since `WheelFile` write methods now have `skipdir=True` default (see below), 33 | writing recursively from a directory will no longer produce entries for 34 | directories. This also means, that attempting to write an empty directory (or 35 | any directory, even with `recursive=False`) is no longer possible, unless 36 | `skipdir=False` is specified. 37 | 38 | This does not apply to `writestr_*` methods - attempting to write to an 39 | `arcname` ending in `/` _will_ produce an entry that is visible as a 40 | directory. 41 | - `WheelFile.validate` will now fail and raise `ValueError` if `WHEEL` build 42 | tag field (`.wheeldata.build`) contains a value that is different from the 43 | wheel name (`.build_tag`). 44 | 45 | ### Added 46 | - `WheelFile.from_wheelfile` - a constructor class-method that makes it 47 | possible to recreate a wheel and: rename it (change distname, version, 48 | buildnumber and/or tags), append files to it, change its metadata, etc. 49 | - `WheelFile.METADATA_FILENAMES` - a static field with a set of names of 50 | metadata files managed by this class. 51 | - `WheelFile.writestr_distinfo` - similar to `write_distinfo`, this is a safe 52 | shortcut for writing into `.dist-info` directory. 53 | - `WheelFile.__init__` now takes configuration arguments known from `ZipFile`: 54 | `compression`, `compression`, `allowZip64`, and `strict_timestamps`. They 55 | work the same way, except that they are keyword only in `WheelFile`, and the 56 | default value for `compression` is `zipfile.ZIP_DEFLATED`. 57 | - `WheelFile` write methods now take optional `compress_type` and 58 | `compresslevel` arguments known from `ZipFile`. 59 | - New `skipdir` argument in `WheelFile` write methods: `write`, `write_data`, 60 | and `write_distinfo`. When `True` (which is the default), these methods will 61 | not write ZIP entries for directories into the archive. 62 | 63 | ### Fixed 64 | - Docstring of the `WheelFile.filename` property, which was innacurate. 65 | - `MetaData.from_str` will now correctly unpack `Keywords` field into a list of 66 | strings, instead of a one-element list with a string containing 67 | comma-separated tags. 68 | 69 | ## [0.0.7] - 2021-07-19 70 | ### Changed 71 | - Default compression method is now set to `zipfile.ZIP_DEFLATED`. 72 | - Wheels with directory entries in their `RECORD` files will now make 73 | `WheelFile` raise `RecordContainsDirectoryError`. 74 | - Lazy mode is now allowed, in a very limited version - most methods will still 75 | raise exceptions, even when the documentation states that lazy mode 76 | suppresses them. 77 | 78 | Use it by specifying `l` in the `mode` argument, e.g. 79 | `WheelFile("path/to/wheel", mode='rl')`. This may be used to read wheels 80 | generated with previous version of wheelfile, which generated directory 81 | entries in `RECORD`, making them incompatible with this release. 82 | 83 | - In anticipation of an actual implementation, `WheelFile.open()` raises 84 | `NotImplementedError` now, as it should. Previously only a `def ...: pass` 85 | stub was present. 86 | 87 | ### Added 88 | - Implemented `WheelFile.namelist()`, which, similarily to `ZipFile.namelist()`, 89 | returns a list of archive members, but omits the metadata files which should 90 | not be written manually: `RECORD`, `WHEEL` and `METADATA`. 91 | - Added `WheelFile.infolist()`. Similarily to the `namelist()` above - it 92 | returns a `ZipInfo` for each member, but omits the ones corresponding to 93 | metadata files. 94 | - `RecordContainsDirectoryError` exception class. 95 | - `distinfo_dirname` and `data_dirname` properties, for easier browsing. 96 | 97 | ### Fixed 98 | - Wheel contents written using `write(..., recursive=True)` no longer contain 99 | entries corresponding to directories in their `RECORD`. 100 | - Removed a bunch of cosmetic mistakes from exception messages. 101 | 102 | ## [0.0.6] - 2021-07-01 103 | 104 | *This release introduces backwards-incompatible changes in `WheelFile.write`.* 105 | Overall, it makes the method safer and easier to use. One will no longer create 106 | a wheel-bomb by calling `write('./')`. 107 | 108 | If you were passing relative paths as `filename` without setting `arcname`, you 109 | probably want to set `resolve=False` for retaining compatibility with this 110 | release. See the "Changed" section. 111 | 112 | ### Added 113 | - `WheelFile.write` and `WheelFile.write_data` now have a new, keyword-only 114 | `resolve` argument, that substitutes the default `arcname` with the name of 115 | the file the path in `filename` points to. This is set to `True` by default 116 | now. 117 | - New `WheelFile.write_distinfo` method, as a safe shorthand for writing to 118 | `.dist-info/`. 119 | - New `resolved` utility function. 120 | - New `ProhibitedWriteError` exception class. 121 | 122 | ### Changed 123 | - `WheelMeta` no longer prohibits reading metadata in versions other than v2.1. 124 | It uses `2.1` afterwards, and its still not changeable though. 125 | - Since `WheelFile.write` and `WheelFile.write_data` methods have `resolve` 126 | argument set to `True` by default now, paths are no longer being put verbatim 127 | into the archive, only the filenames they point to. Set `resolve` to `False` 128 | to get the old behavior, the one exhibited by `ZipFile.write`. 129 | - Parts of `WheelFile.__init__` have been refactored for parity between "named" 130 | and "unnamed" modes, i.e. it no longer raises different exceptions based on 131 | whether it is given a file, path to a directory, path to a file, or an io 132 | buffer. 133 | - Wheels generated by `WheelFile` are now reproducible. The modification times 134 | written into the resulting archives using `.write(...)` no longer differ 135 | between builds consisting of the same, unchanged files - they are taken from 136 | the files itself. 137 | 138 | ### Fixed 139 | - `WheelFile` no longer accepts arguments of types other than `Version` and 140 | `str` in its `version` argument, when an io buffer is given. `TypeError` is 141 | raised instead. 142 | - `MetaData` started accepting keywords given via single string (comma 143 | separated). Previously this support was documented, but missing. 144 | - The `wheelfile` package itself should now have the keywords set properly ;). 145 | 146 | ## [0.0.5] - 2021-05-12 147 | ### Fixed 148 | - Added `ZipInfo38` requirement - v0.0.4 has been released without it by 149 | mistake. 150 | 151 | ## [0.0.4] - 2021-05-05 152 | ### Added 153 | - `WheelFile.write` and `WheelFile.write_data` now accept a `recursive` 154 | keyword-only argument, which makes both of them recursively add the whole 155 | directory subtree, if the `filename` argument was pointing at one. 156 | 157 | ### Changed 158 | - `WheelFile.write` and `WheelFile.write_data` are recursive by default. 159 | - Backported support to python 3.6 (thanks, 160 | [e2thenegpii](https://github.com/e2thenegpii)!) 161 | 162 | ## [0.0.3] - 2021-03-28 163 | 164 | Big thanks to [e2thenegpii](https://github.com/e2thenegpii) for their 165 | contributions - both of the fixes below came from them. 166 | 167 | ### Changed 168 | - Fixed an issue breaking causing long METADATA lines to be broken into 169 | multiple shorter lines 170 | - Fixed the production of RECORD files to encode file hashes with base64 171 | per PEP 376 172 | 173 | ## [0.0.2] - 2021-01-24 174 | ### Added 175 | - Read mode (`'r'`) now works. 176 | - Added `write_data` and `writestr_data` methods to `WheelFile` class. Use 177 | these methods to write files to `.data/` directory of the wheel. 178 | - Added `build_tag` and `language_tag`, `abi_tag`, and `platform_tag` 179 | parameters to `WheelFile.__init__`, with their respective properties. 180 | - Tag attributes mentioned above can also be inferred from the filename of the 181 | specified file. 182 | - Accessing the mode with which the wheelfile was opened is now possible using 183 | `mode` attribute. 184 | 185 | ### Changed 186 | - Default tag set of `WheelData` class is now `['py3-none-any']`. Previously, 187 | universal tag (`"py2.py3-none-any"`) was used. 188 | - Fixed issues with comparing `MetaData` objects that have empty descriptions. 189 | After parsing a metadata text with empty payload, the returned object has an 190 | empty string inside description, instead of `None`, which is used by 191 | `MetaData.__init__` to denote that no description was provided. This means 192 | that these two values are the effectively the same thing in this context. 193 | `MetaData.__eq__` now refelcts that. 194 | 195 | ## [0.0.1] - 2021-01-16 196 | ### Added 197 | - First working version of the library. 198 | - It's possible to create wheels from scratch. 199 | 200 | [Unreleased]: https://github.com/mrmino/wheelfile/compare/v0.0.9...HEAD 201 | [0.0.9]: https://github.com/mrmino/wheelfile/compare/v0.0.8...v0.0.9 202 | [0.0.8]: https://github.com/mrmino/wheelfile/compare/v0.0.7...v0.0.8 203 | [0.0.7]: https://github.com/mrmino/wheelfile/compare/v0.0.6...v0.0.7 204 | [0.0.6]: https://github.com/mrmino/wheelfile/compare/v0.0.5...v0.0.6 205 | [0.0.5]: https://github.com/mrmino/wheelfile/compare/v0.0.4...v0.0.5 206 | [0.0.4]: https://github.com/mrmino/wheelfile/compare/v0.0.3...v0.0.4 207 | [0.0.3]: https://github.com/mrmino/wheelfile/compare/v0.0.2...v0.0.3 208 | [0.0.2]: https://github.com/mrmino/wheelfile/compare/v0.0.1...v0.0.2 209 | [0.0.1]: https://github.com/mrmino/wheelfile/releases/tags/v0.0.1 210 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2021 Blazej Michalik 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 32 | 76 | 77 | -------------------------------------------------------------------------------- /__build__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any, Dict 3 | 4 | from wheelfile import WheelFile, __version__ 5 | 6 | # For WheelFile.__init__ 7 | # Platform, abi, and language tags stay as defaults: "py3-none-any" 8 | spec: Dict[str, Any] = { 9 | "distname": "wheelfile", 10 | "version": __version__, 11 | } 12 | 13 | # Fetch requirements into a list of strings 14 | requirements = Path("./requirements.txt").read_text().splitlines() 15 | 16 | # Open a new wheel file 17 | with WheelFile(mode="w", **spec) as wf: 18 | # Add requirements to the metadata 19 | wf.metadata.requires_dists = requirements 20 | # We target Python 3.9+ only 21 | wf.metadata.requires_python = ">=3.9" 22 | 23 | # Make sure PyPI page renders nicely 24 | wf.metadata.summary = "API for inspecting and creating .whl files" 25 | wf.metadata.description = Path("./README.md").read_text() 26 | wf.metadata.description_content_type = "text/markdown" 27 | 28 | # Keywords and trove classifiers, for better searchability 29 | wf.metadata.keywords = ["wheel", "packaging", "pip", "build", "distutils"] 30 | wf.metadata.classifiers = [ 31 | "Development Status :: 2 - Pre-Alpha", 32 | "Intended Audience :: Developers", 33 | "License :: OSI Approved :: MIT License", 34 | "Topic :: Software Development :: Build Tools", 35 | "Topic :: Software Development :: Libraries", 36 | "Topic :: System :: Archiving :: Packaging", 37 | "Topic :: System :: Software Distribution", 38 | "Topic :: Utilities", 39 | ] 40 | 41 | # Let the world know who is responsible for this 42 | wf.metadata.author = "Błażej Michalik" 43 | wf.metadata.home_page = "https://github.com/MrMino/wheelfile" 44 | 45 | # Add the code - it will install inside site-packages/wheelfile.py 46 | wf.write("./wheelfile.py") 47 | 48 | # Done! 49 | # 🧀 50 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | # Makes autodoc look one directory above 5 | sys.path.insert(0, os.path.abspath("..")) 6 | 7 | project = "wheelfile" 8 | copyright = "2021, Błażej Michalik" 9 | author = "MrMino" 10 | extensions = [ 11 | "sphinx.ext.autodoc", 12 | "sphinx.ext.napoleon", 13 | ] 14 | html_theme = "furo" 15 | -------------------------------------------------------------------------------- /docs/examples/build_pep517_example.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | from wheelfile import WheelFile 4 | 5 | main = """ 6 | import subprocess, os, platform 7 | import pathlib 8 | 9 | if __name__ == '__main__': 10 | filepath = pathlib.Path(__file__).parent / 'builder.py' 11 | if platform.system() == 'Darwin': 12 | subprocess.call(('open', filepath)) 13 | elif platform.system() == 'Windows': 14 | os.startfile(filepath) 15 | else: 16 | subprocess.call(('xdg-open', filepath)) 17 | """ 18 | 19 | with WheelFile(mode="w", distname="pep_517_example", version="1", build_tag=4) as wf: 20 | wf.metadata.requires_dists = ["wheelfile", "toml"] 21 | wf.metadata.summary = "Example of PEP-517 builder that uses wheelfile" 22 | wf.metadata.description = ( 23 | "See " 24 | "https://wheelfile.readthedocs.io/en/latest/" 25 | "examples/pep-517-builder.html" 26 | ) 27 | wf.metadata.description_content_type = "text/markdown" 28 | wf.metadata.keywords = ["wheel", "packaging", "pip", "build", "pep-517"] 29 | wf.metadata.classifiers = [ 30 | "Intended Audience :: Developers", 31 | "License :: OSI Approved :: MIT License", 32 | ] 33 | wf.metadata.author = "Błażej Michalik" 34 | wf.metadata.home_page = "https://github.com/MrMino/wheelfile" 35 | 36 | wf.write( 37 | pathlib.Path(__file__).parent / "./pep_517_builder.py", 38 | "pep_517_example/builder.py", 39 | ) 40 | wf.writestr("pep_517_example/__main__.py", main) 41 | 42 | # 🧀 43 | -------------------------------------------------------------------------------- /docs/examples/buildscript.rst: -------------------------------------------------------------------------------- 1 | A fully fledged buildscript 2 | =========================== 3 | 4 | The following script is the actual build script used to package ``wheelfile`` 5 | during a release. 6 | 7 | .. literalinclude:: ../../__build__.py 8 | -------------------------------------------------------------------------------- /docs/examples/pep-517-builder.rst: -------------------------------------------------------------------------------- 1 | PEP-517 Builder 2 | =============== 3 | 4 | Ever wanted to create a package builder like ``setuptools``? 5 | 6 | Thanks to `PEP-517 `__ and `PEP-518 7 | `__ you can create your very own 8 | package builders, and make them compatible with ``pip install 9 | path/to/repository``, ``pip wheel``, and all sorts of other tools. 10 | 11 | ``wheelfile`` makes developing your own builder & hooks straightforward. 12 | 13 | The code at the bottom of this page shows an example of a simple builder that 14 | can create bdist and sdist distributions from a project tree with source code 15 | inside ``src/`` directory. 16 | 17 | How to use this example 18 | ----------------------- 19 | Package builders are expected to be installed inside the environment site - the 20 | project tree is not included in the import search path when looking for the 21 | hooks. 22 | In order to make this example easier to follow, its code has been packaged into 23 | ``pep-517-example`` package, and uploaded to PyPI, so that you can install it 24 | and fiddle with mechanics right away, without having to create your own 25 | package. 26 | 27 | You can install this example using:: 28 | 29 | pip install pep-517-example 30 | 31 | Inside this package, there is a simple entry point script that opens the 32 | builder source inside an editor. You can run it using:: 33 | 34 | python -m pep_517_example 35 | 36 | Project configuration: pyproject.toml 37 | ------------------------------------- 38 | PEP-518 and the example builder expect the project tree to include a 39 | ``pyproject.toml`` file. This file specifies the builder that should be used 40 | for creating the package (and installing it afterwards, if ``pip install`` is 41 | used). Additionally, the builder reads its own configuration from this file. 42 | 43 | The builder reads the file using ``get_config()`` function. 44 | 45 | Here is an example of the contents of this file:: 46 | 47 | [build-system] 48 | requires = ['pep-517-example'] 49 | build-backend = 'pep_517_example.builder' 50 | 51 | [tool.pep_517_example] 52 | name = "my_package" 53 | version = "1.0.0" 54 | maintainers = "Jack Sparrow" 55 | maintainers_emails = "sparrow.jack@pearl.black" 56 | 57 | 58 | Project tree & building a wheel using pip 59 | ----------------------------------------- 60 | Another file that our builder will expect, is ``requirements.txt``. To sum up, 61 | a project directory that this builder expects should have the following 62 | structure:: 63 | 64 | . 65 | ├── pyproject.toml 66 | ├── requirements.txt 67 | └── src 68 | ├── ... 69 | └── app.py 70 | 71 | 72 | After navigating to this directory, you can use the following `pip` command to 73 | build a wheel:: 74 | 75 | pip wheel . --no-build-isolation 76 | 77 | This command will make ``pip`` politely run the builder hooks over the project 78 | directory tree. 79 | 80 | The ``--no-build-isolation`` flag will make `pip` use the builder installed 81 | within your environment (the ``pep_517_example`` one), instead of downloading 82 | it with all of its dependencies from scratch. 83 | 84 | After issuing the command above, you should see a wheel po up in the directory 85 | you're currently in:: 86 | 87 | my_package-1.0.0-py3-none-any.whl 88 | 89 | Builder source code 90 | ------------------- 91 | 92 | .. literalinclude:: ./pep_517_builder.py 93 | -------------------------------------------------------------------------------- /docs/examples/pep_517_builder.py: -------------------------------------------------------------------------------- 1 | import tarfile 2 | from pathlib import Path 3 | 4 | import toml 5 | 6 | from wheelfile import WheelFile 7 | 8 | 9 | def get_config(): 10 | """Read pyproject.toml""" 11 | project_config = toml.load("pyproject.toml") 12 | config = project_config["tool"]["pep_517_example"] 13 | return config 14 | 15 | 16 | # See https://www.python.org/dev/peps/pep-0517/#get-requires-for-build-wheel 17 | def get_requires_for_build_wheel(config_settings): 18 | return [] 19 | 20 | 21 | # See https://www.python.org/dev/peps/pep-0517/#build-sdist 22 | def build_sdist(sdist_directory, config_settings=None): 23 | config = get_config() 24 | distname = config["name"] 25 | version = config["version"] 26 | 27 | with tarfile.open( 28 | f"{distname}-{version}.tar.gz", "w:gz", format=tarfile.PAX_FORMAT 29 | ) as sdist: 30 | sdist.add("./") 31 | 32 | 33 | # See https://www.python.org/dev/peps/pep-0517/#build-wheel 34 | def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): 35 | config = get_config() 36 | 37 | maintainers = config["maintainers"] 38 | if isinstance(maintainers, list): 39 | maintainers = ", ".join(maintainers) 40 | 41 | maintainers_emails = config["maintainers_emails"] 42 | if isinstance(maintainers_emails, list): 43 | maintainers_emails = ", ".join(config["maintainers_emails"]) 44 | 45 | requirements = Path("requirements.txt").read_text().splitlines() 46 | 47 | spec = { 48 | "distname": config["name"], 49 | "version": config["version"], 50 | } 51 | 52 | with WheelFile(wheel_directory, "w", **spec) as wf: 53 | wf.metadata.maintainer = maintainers 54 | wf.metadata.maintainer_email = maintainers_emails 55 | wf.metadata.requires_dists = requirements 56 | 57 | wf.write("src/") 58 | 59 | return wf.filename # 🧀 60 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | wheelfile 2 | ========= 3 | 4 | 5 | .. automodule:: wheelfile 6 | 7 | Other examples 8 | -------------- 9 | 10 | Here's a list of more in-depth examples. Each example is based on a real piece 11 | of software that's used "in the wild" at the time of writing. 12 | 13 | .. toctree:: 14 | :maxdepth: 1 15 | 16 | examples/buildscript.rst 17 | examples/pep-517-builder.rst 18 | 19 | Installation 20 | ------------ 21 | 22 | To be able to use the module, you have to install it first:: 23 | 24 | pip install wheelfile 25 | 26 | Main class 27 | ---------- 28 | .. autoclass:: WheelFile 29 | :special-members: 30 | :members: 31 | 32 | Metadata classes 33 | ---------------- 34 | .. autoclass:: WheelRecord 35 | :special-members: 36 | :members: 37 | 38 | .. autoclass:: WheelData 39 | :special-members: 40 | :members: 41 | 42 | .. autoclass:: MetaData 43 | :special-members: 44 | :members: 45 | 46 | Exceptions 47 | ---------- 48 | .. autoexception:: BadWheelFileError 49 | 50 | .. autoexception:: UnnamedDistributionError 51 | 52 | .. autoexception:: ProhibitedWriteError 53 | 54 | 55 | Utilities 56 | --------- 57 | .. autofunction:: resolved 58 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | furo 3 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # This pyproject.toml file is currently only used to keep all tooling config in 2 | # one place. It should not be used to install the package via `pip install .` 3 | # or similar. 4 | 5 | [tool.isort] 6 | profile = "black" 7 | split_on_trailing_comma = true 8 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | # Documentation 4 | -r docs/requirements.txt 5 | 6 | # Tests 7 | pre-commit 8 | pytest 9 | mypy 10 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | packaging >= 20.8 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MrMino/wheelfile/131905ccd22fd63a0b547b8c0cc3e8ef26736d89/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | 3 | import pytest 4 | 5 | from wheelfile import WheelFile 6 | 7 | 8 | @pytest.fixture 9 | def buf(): 10 | return BytesIO() 11 | 12 | 13 | @pytest.fixture 14 | def wf(buf): 15 | wf = WheelFile(buf, "w", distname="_", version="0") 16 | yield wf 17 | wf.close() 18 | 19 | 20 | @pytest.fixture 21 | def tmp_file(tmp_path): 22 | fp = tmp_path / "wheel-0-py3-none-any.whl" 23 | fp.touch() 24 | return fp 25 | -------------------------------------------------------------------------------- /tests/test_corrupted_metadata.py: -------------------------------------------------------------------------------- 1 | from wheelfile import WheelFile 2 | 3 | 4 | def test_when_metadata_is_corrupted_sets_metadata_to_none(buf): 5 | wf = WheelFile(buf, distname="_", version="0", mode="w") 6 | wf.metadata = "This is not a valid metadata" # type: ignore 7 | wf.close() 8 | 9 | with WheelFile(buf, distname="_", version="0", mode="rl") as broken_wf: 10 | assert broken_wf.metadata is None 11 | 12 | 13 | def test_when_wheeldata_is_corrupted_sets_wheeldata_to_none(buf): 14 | wf = WheelFile(buf, distname="_", version="0", mode="w") 15 | wf.wheeldata = "This is not a valid wheeldata" # type: ignore 16 | wf.close() 17 | 18 | with WheelFile(buf, distname="_", version="0", mode="rl") as broken_wf: 19 | assert broken_wf.wheeldata is None 20 | 21 | 22 | def test_wheeldata_is_read_even_if_metadata_corrupted(buf): 23 | wf = WheelFile(buf, distname="_", version="0", mode="w") 24 | wf.metadata = "This is not a valid metadata" # type: ignore 25 | wf.close() 26 | 27 | with WheelFile(buf, distname="_", version="0", mode="rl") as broken_wf: 28 | assert broken_wf.wheeldata is not None 29 | 30 | 31 | def test_metadata_is_read_even_if_wheeldata_corrupted(buf): 32 | wf = WheelFile(buf, distname="_", version="0", mode="w") 33 | wf.wheeldata = "This is not a valid wheeldata" # type: ignore 34 | wf.close() 35 | 36 | with WheelFile(buf, distname="_", version="0", mode="rl") as broken_wf: 37 | assert broken_wf.metadata is not None 38 | -------------------------------------------------------------------------------- /tests/test_lazy_mode.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from wheelfile import WheelFile 4 | 5 | 6 | @pytest.mark.filterwarnings("ignore:Lazy mode is not fully implemented yet.") 7 | def test_lazy_mode_is_available(buf): 8 | WheelFile(buf, mode="wl", distname="dist", version="0") 9 | 10 | 11 | class TestLazyModeRecord: 12 | 13 | @pytest.mark.skip 14 | def test_suppresses_record_containing_directory_entries(self): 15 | raise NotImplementedError 16 | -------------------------------------------------------------------------------- /tests/test_metas.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | from textwrap import dedent 3 | 4 | import pytest 5 | from packaging.version import Version 6 | 7 | from wheelfile import ( 8 | MetaData, 9 | RecordContainsDirectoryError, 10 | UnsupportedHashTypeError, 11 | WheelData, 12 | WheelRecord, 13 | ) 14 | from wheelfile import __version__ as lib_version 15 | 16 | 17 | class TestMetadata: 18 | plurals = { 19 | "keywords", 20 | "classifiers", 21 | "project_urls", 22 | "platforms", 23 | "supported_platforms", 24 | "requires_dists", 25 | "requires_externals", 26 | "provides_extras", 27 | "provides_dists", 28 | "obsoletes_dists", 29 | } 30 | 31 | def test_only_name_and_version_is_required(self): 32 | md = MetaData(name="my-package", version="1.2.3") 33 | assert md.name == "my-package" and str(md.version) == "1.2.3" 34 | 35 | @pytest.fixture 36 | def metadata(self): 37 | return MetaData(name="my-package", version="1.2.3") 38 | 39 | def test_basic_eq(self): 40 | args = {"name": "x", "version": "1"} 41 | assert MetaData(**args) == MetaData(**args) 42 | 43 | def test_basic_to_str(self, metadata): 44 | expected = dedent( 45 | """\ 46 | Metadata-Version: 2.1 47 | Name: my-package 48 | Version: 1.2.3 49 | 50 | """ 51 | ) 52 | assert str(metadata) == expected 53 | 54 | def test_basic_from_str(self, metadata): 55 | assert str(MetaData.from_str(str(metadata))) == str(metadata) 56 | 57 | def test_to_and_fro_str_objects_are_equal(self, metadata): 58 | assert metadata == MetaData.from_str(str(metadata)) 59 | 60 | def test_metadata_version_is_2_1(self, metadata): 61 | assert metadata.metadata_version == "2.1" 62 | 63 | def test_metadata_version_is_unchangeable(self, metadata): 64 | with pytest.raises(AttributeError): 65 | metadata.metadata_version = "3.0" 66 | 67 | @pytest.mark.parametrize("field", plurals) 68 | def test_plural_params_default_to_empty_lists(self, metadata, field): 69 | # Each of the attribute names here should end with an "s". 70 | assert getattr(metadata, field) == [] 71 | 72 | @pytest.mark.parametrize("field", plurals - {"keywords"}) 73 | def test_plural_fields_except_keywords_show_up_as_multiple_use(self, field): 74 | assert MetaData.field_is_multiple_use(field) 75 | 76 | def test_keywords_is_not_multiple_use(self): 77 | assert not MetaData.field_is_multiple_use("keywords") 78 | 79 | @classmethod 80 | @pytest.fixture 81 | def full_usage(cls): 82 | description = dedent( 83 | """\ 84 | 85 | Some 86 | 87 | Long 88 | 89 | Description 90 | """ 91 | ) 92 | kwargs = { 93 | "name": "package-name", 94 | "version": Version("1.2.3"), 95 | "summary": "this is a test", 96 | "description": description, 97 | "description_content_type": "text/plain", 98 | "keywords": ["test", "unittests", "package", "wheelfile"], 99 | "classifiers": [ 100 | "Topic :: Software Development :: Testing", 101 | "Framework :: Pytest", 102 | ], 103 | "author": "MrMino", 104 | "author_email": "mrmino@example.com", 105 | "maintainer": "NotMrMino", 106 | "maintainer_email": "not.mrmino@example.com", 107 | "license": "May be distributed only if this test succeeds", 108 | "home_page": "http://example.com/package-name/1.2.3", 109 | "download_url": "http://example.com/package-name/1.2.3/download", 110 | "project_urls": ["Details: http://example.com/package-name/"], 111 | "platforms": ["SomeOS", "SomeOtherOS"], 112 | "supported_platforms": ["some-architecture-128", "my-mips-21"], 113 | "requires_python": "~=3.6", 114 | "requires_dists": ["wheelfile[metadata]~=1.0", "paramiko"], 115 | "requires_externals": ["vim", "zsh"], 116 | "provides_extras": ["metadata"], 117 | "provides_dists": ["wheel_packaging"], 118 | "obsoletes_dists": ["wheel"], 119 | } 120 | 121 | return kwargs 122 | 123 | def test_params_are_memorized(self, full_usage): 124 | md = MetaData(**full_usage) 125 | for field, value in full_usage.items(): 126 | assert getattr(md, field) == value 127 | 128 | def test_metadata_text_generation(self, full_usage): 129 | # Order of the header lines is NOT tested (order in payload - is) 130 | expected_headers = dedent( 131 | """\ 132 | Metadata-Version: 2.1 133 | Name: package-name 134 | Version: 1.2.3 135 | Platform: SomeOS 136 | Platform: SomeOtherOS 137 | Supported-Platform: some-architecture-128 138 | Supported-Platform: my-mips-21 139 | Summary: this is a test 140 | Description-Content-Type: text/plain 141 | Keywords: test,unittests,package,wheelfile 142 | License: May be distributed only if this test succeeds 143 | Home-page: http://example.com/package-name/1.2.3 144 | Download-URL: http://example.com/package-name/1.2.3/download 145 | Author: MrMino 146 | Author-email: mrmino@example.com 147 | Maintainer: NotMrMino 148 | Maintainer-email: not.mrmino@example.com 149 | Classifier: Topic :: Software Development :: Testing 150 | Classifier: Framework :: Pytest 151 | Requires-Dist: wheelfile[metadata]~=1.0 152 | Requires-Dist: paramiko 153 | Requires-Python: ~=3.6 154 | Requires-External: vim 155 | Requires-External: zsh 156 | Project-URL: Details: http://example.com/package-name/ 157 | Provides-Extra: metadata 158 | Provides-Dist: wheel_packaging 159 | Obsoletes-Dist: wheel 160 | """ 161 | ).splitlines() 162 | expected_payload = dedent( 163 | """\ 164 | 165 | 166 | Some 167 | 168 | Long 169 | 170 | Description 171 | """ 172 | ).splitlines() 173 | 174 | lines = str(MetaData(**full_usage)).splitlines() 175 | header_end_idx = lines.index("") 176 | headers = lines[:header_end_idx] 177 | payload = lines[header_end_idx:] 178 | assert set(headers) == set(expected_headers) 179 | assert payload == expected_payload 180 | 181 | def test_full_usage_from_str_eqs_by_str(self, full_usage): 182 | md = MetaData(**full_usage) 183 | fs = MetaData.from_str(str(md)) 184 | assert str(fs) == str(md) 185 | 186 | def test_full_usage_from_str_eqs_by_obj(self, full_usage): 187 | md = MetaData(**full_usage) 188 | fs = MetaData.from_str(str(md)) 189 | assert fs == md 190 | 191 | def test_no_mistaken_attributes(self, metadata): 192 | with pytest.raises(AttributeError): 193 | metadata.maintainers = "" 194 | 195 | with pytest.raises(AttributeError): 196 | metadata.Description = "" 197 | 198 | with pytest.raises(AttributeError): 199 | metadata.clasifiers = [] 200 | 201 | def test_there_are_24_fields_in_this_metadata_version(self): 202 | assert len([field for field in MetaData.__slots__] + ["metadata_version"]) == 24 203 | 204 | def test_keywords_param_accepts_comma_separated_str(self): 205 | metadata = MetaData(name="name", version="1.2.3", keywords="a,b,c") 206 | assert metadata.keywords == ["a", "b", "c"] 207 | 208 | def test_keywords_param_accepts_list(self): 209 | metadata = MetaData(name="name", version="1.2.3", keywords=["a", "b", "c"]) 210 | assert metadata.keywords == ["a", "b", "c"] 211 | 212 | 213 | class TestWheelData: 214 | def test_simple_init(self): 215 | wm = WheelData() 216 | assert ( 217 | wm.generator.startswith("wheelfile ") 218 | and wm.root_is_purelib is True 219 | and wm.tags == ["py3-none-any"] 220 | and wm.build is None 221 | ) 222 | 223 | def test_init_args(self): 224 | args = {} 225 | args.update( 226 | generator="test", root_is_purelib=False, tags="my-awesome-tag", build=2 227 | ) 228 | wm = WheelData(**args) 229 | 230 | assert ( 231 | wm.generator == args["generator"] 232 | and wm.root_is_purelib == args["root_is_purelib"] 233 | and set(wm.tags) == set([args["tags"]]) 234 | and wm.build == args["build"] 235 | ) 236 | 237 | def test_tags_are_extended(self): 238 | wm = WheelData(tags=["py2.py3-none-any", "py2-cp3.cp2-manylinux1"]) 239 | expected_tags = [ 240 | "py2-none-any", 241 | "py3-none-any", 242 | "py2-cp3-manylinux1", 243 | "py2-cp2-manylinux1", 244 | ] 245 | assert set(wm.tags) == set(expected_tags) 246 | 247 | def test_single_tag_is_extended(self): 248 | wm = WheelData(tags="py2.py3-none-any") 249 | expected_tags = [ 250 | "py2-none-any", 251 | "py3-none-any", 252 | ] 253 | assert set(wm.tags) == set(expected_tags) 254 | 255 | def test_wheel_version_is_1_0(self): 256 | assert WheelData().wheel_version == "1.0" 257 | 258 | def test_wheel_version_is_not_settable(self): 259 | with pytest.raises(AttributeError): 260 | WheelData().wheel_version = "2.0" 261 | 262 | def test_strignifies_into_valid_wheelmeta(self): 263 | expected_contents = dedent( 264 | f"""\ 265 | Wheel-Version: 1.0 266 | Generator: wheelfile {lib_version} 267 | Root-Is-Purelib: true 268 | Tag: py2-none-any 269 | Build: 123 270 | 271 | """ 272 | ) 273 | wm = WheelData(tags="py2-none-any", build=123) 274 | assert str(wm) == expected_contents 275 | 276 | def test_changing_attributes_changes_str(self): 277 | wm = WheelData() 278 | wm.generator = "test" 279 | wm.root_is_purelib = False 280 | wm.tags = ["my-test-tag", "another-test-tag"] 281 | wm.build = 12345 282 | 283 | expected_contents = dedent( 284 | """\ 285 | Wheel-Version: 1.0 286 | Generator: test 287 | Root-Is-Purelib: false 288 | Tag: my-test-tag 289 | Tag: another-test-tag 290 | Build: 12345 291 | 292 | """ 293 | ) 294 | 295 | assert str(wm) == expected_contents 296 | 297 | def test_breaks_when_multiple_use_arg_is_given_a_single_string(self): 298 | wm = WheelData() 299 | wm.tags = "this is a tag" 300 | 301 | with pytest.raises(AssertionError): 302 | str(wm) 303 | 304 | def test_no_mistaken_attributes(self): 305 | wm = WheelData() 306 | 307 | with pytest.raises(AttributeError): 308 | wm.root_is_platlib = "" 309 | 310 | with pytest.raises(AttributeError): 311 | wm.tag = "" 312 | 313 | with pytest.raises(AttributeError): 314 | wm.generated_by = "" 315 | 316 | def test_instances_are_comparable(self): 317 | assert WheelData() == WheelData() 318 | 319 | def test_different_instances_compare_negatively(self): 320 | wm_a = WheelData() 321 | wm_b = WheelData() 322 | 323 | wm_b.build = 10 324 | 325 | assert wm_a != wm_b 326 | 327 | def test_from_str_eqs_by_string(self): 328 | assert str(WheelData.from_str(str(WheelData()))) == str(WheelData()) 329 | 330 | def test_from_str_eqs_by_obj(self): 331 | assert WheelData.from_str(str(WheelData())) == WheelData() 332 | 333 | 334 | class TestWheelRecord: 335 | @pytest.fixture 336 | def record(self): 337 | return WheelRecord() 338 | 339 | def test_after_adding_a_file_its_hash_is_available(self, record): 340 | buf = BytesIO(bytes(1000)) 341 | expected_hash = "sha256=VBs-naoJsgv4X6Jz5cvT6AGFqk7CmOdl24d0K3ATilM" 342 | record.update("file", buf) 343 | assert record.hash_of("file") == expected_hash 344 | 345 | def test_empty_stringifies_to_empty_string(self, record): 346 | assert str(record) == "" 347 | 348 | def test_stringifies_to_proper_format(self, record): 349 | size = 1000 350 | buf = BytesIO(bytes(size)) 351 | expected_hash = "sha256=VBs-naoJsgv4X6Jz5cvT6AGFqk7CmOdl24d0K3ATilM" 352 | record.update("file", buf) 353 | buf.seek(0) 354 | record.update("another/file", buf) 355 | 356 | # CSV uses CRLF by default, hence \r-s 357 | expected_record = dedent( 358 | f"""\ 359 | file,{expected_hash},{size}\r 360 | another/file,{expected_hash},{size}\r 361 | """ 362 | ) 363 | 364 | assert str(record) == expected_record 365 | 366 | def test_removing_file_removes_it_from_str_repr(self, record): 367 | buf = BytesIO(bytes(1000)) 368 | record.update("file", buf) 369 | record.remove("file") 370 | assert str(record) == "" 371 | 372 | def test_two_empty_records_are_equal(self): 373 | assert WheelRecord() == WheelRecord() 374 | 375 | def test_adding_same_files_to_two_records_make_them_equal(self): 376 | a = WheelRecord() 377 | b = WheelRecord() 378 | buf = BytesIO(bytes(1000)) 379 | a.update("file", buf) 380 | buf.seek(0) 381 | b.update("file", buf) 382 | assert a == b 383 | 384 | def test_from_empty_str_produces_empty_record(self): 385 | assert str(WheelRecord.from_str("")) == "" 386 | 387 | def test_stringification_is_stable(self): 388 | wr = WheelRecord() 389 | buf = BytesIO(bytes(1000)) 390 | wr.update("file", buf) 391 | assert str(WheelRecord.from_str(str(wr))) == str(wr) 392 | 393 | def test_has_membership_operator_for_paths_in_the_record(self): 394 | wr = WheelRecord() 395 | wr.update("some/particular/path", BytesIO(bytes(1))) 396 | assert "some/particular/path" in wr 397 | 398 | def test_throws_with_unknown_hash(self): 399 | with pytest.raises(UnsupportedHashTypeError): 400 | WheelRecord(hash_algo="frobnots") 401 | 402 | @pytest.mark.parametrize("hash_algo", ("md5", "sha1")) 403 | def test_throw_with_bad_hash(self, hash_algo): 404 | with pytest.raises(UnsupportedHashTypeError): 405 | WheelRecord(hash_algo=hash_algo) 406 | 407 | def test_update_throws_on_directory_entry(self): 408 | with pytest.raises(RecordContainsDirectoryError): 409 | wr = WheelRecord() 410 | wr.update("path/to/a/directory/", BytesIO(bytes(1))) 411 | 412 | def test_from_str_throws_on_directory_entry(self): 413 | with pytest.raises(RecordContainsDirectoryError): 414 | record_str = "./,sha256=whatever,0" 415 | WheelRecord.from_str(record_str) 416 | -------------------------------------------------------------------------------- /tests/test_resolved.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from wheelfile import resolved 4 | 5 | 6 | def test_resolved_func(tmp_path): 7 | back = os.getcwd() 8 | os.chdir(tmp_path) 9 | 10 | assert resolved(os.curdir) == str(tmp_path.name) 11 | assert resolved(tmp_path) == str(tmp_path.name) 12 | assert resolved("dir/file") == "file" 13 | assert resolved("dir/../dir2/../file") == "file" 14 | 15 | os.chdir(back) 16 | -------------------------------------------------------------------------------- /tests/test_wheel_gen.py: -------------------------------------------------------------------------------- 1 | from zipfile import Path as ZipPath 2 | from zipfile import ZipFile 3 | 4 | import pytest 5 | 6 | from wheelfile import WheelFile 7 | 8 | 9 | class TestEmptyWheelStructure: 10 | 11 | distname = "my_dist" 12 | version = "1.0.0" 13 | 14 | @pytest.fixture 15 | def wheelfile(self, buf): 16 | wf = WheelFile(buf, "w", distname=self.distname, version=self.version) 17 | wf.close() 18 | return wf 19 | 20 | @pytest.fixture 21 | def wheel(self, wheelfile, buf): 22 | assert not buf.closed 23 | return ZipFile(buf) 24 | 25 | @pytest.fixture 26 | def distinfo(self, wheel): 27 | return ZipPath(wheel, f"{self.distname}-{self.version}.dist-info/") 28 | 29 | def test_dist_info_is_dir(self, distinfo): 30 | assert distinfo.is_dir() 31 | 32 | def test_has_no_synonym_files(self, wheel): 33 | assert len(set(wheel.namelist())) == len(wheel.namelist()) 34 | 35 | def test_metadata_is_from_wheelfile(self, distinfo, wheelfile): 36 | metadata = distinfo / "METADATA" 37 | assert metadata.read_text() == str(wheelfile.metadata) 38 | 39 | def test_wheeldata_is_from_wheelfile(self, distinfo, wheelfile): 40 | wheeldata = distinfo / "WHEEL" 41 | assert wheeldata.read_text() == str(wheelfile.wheeldata) 42 | 43 | def test_record_is_from_wheelfile(self, distinfo, wheelfile): 44 | record = distinfo / "RECORD" 45 | assert record.read_text() == str(wheelfile.record).replace("\r\n", "\n") 46 | 47 | 48 | class TestLongMetadataLine: 49 | 50 | distname = "my_dist" 51 | version = "1.0.0" 52 | 53 | long_requirement = "a" * 400 54 | 55 | @pytest.fixture 56 | def wheelfile(self, buf): 57 | wf = WheelFile(buf, "w", distname=self.distname, version=self.version) 58 | wf.metadata.requires_dists = [self.long_requirement] 59 | wf.close() 60 | return wf 61 | 62 | @pytest.fixture 63 | def wheel(self, wheelfile, buf): 64 | assert not buf.closed 65 | return ZipFile(buf) 66 | 67 | @pytest.fixture 68 | def distinfo(self, wheel): 69 | return ZipPath(wheel, f"{self.distname}-{self.version}.dist-info/") 70 | 71 | def test_metadata_is_from_wheelfile(self, distinfo, wheelfile): 72 | """Test long lines in METADATA aren't split to multiple shorter lines""" 73 | metadata = distinfo / "METADATA" 74 | assert f"Requires-Dist: {self.long_requirement}" in metadata.read_text() 75 | 76 | 77 | def test_build_reproducibility(tmp_path): 78 | """Two wheels made from the same set of files should be the same""" 79 | (tmp_path / "package").mkdir() 80 | (tmp_path / "package" / "file").touch() 81 | 82 | wf1 = WheelFile(tmp_path / "1.whl", "w", distname="mywheel", version="1") 83 | wf1.write(tmp_path / "package") 84 | wf1.close() 85 | 86 | wf2 = WheelFile(tmp_path / "2.whl", "w", distname="mywheel", version="1") 87 | wf2.write(tmp_path / "package") 88 | wf2.close() 89 | 90 | with open(tmp_path / "1.whl", "rb") as f: 91 | contents_wf1 = f.read() 92 | 93 | with open(tmp_path / "2.whl", "rb") as f: 94 | contents_wf2 = f.read() 95 | 96 | assert contents_wf1 == contents_wf2 97 | 98 | 99 | class TestWritesDoNotMisformatRecordWithDirEntries: 100 | 101 | def test_recursive_writes_dont_misformat_record(self, tmp_path, buf): 102 | (tmp_path / "dir1").mkdir() 103 | written_dir = tmp_path / "dir1" / "dir2" 104 | written_dir.mkdir() 105 | 106 | wf = WheelFile(buf, "w", distname="mywheel", version="1") 107 | wf.write(tmp_path / "dir1", recursive=True) 108 | wf.close() 109 | 110 | wf = WheelFile(buf, "r", distname="mywheel", version="1") 111 | assert str(written_dir) not in str(wf.record) 112 | -------------------------------------------------------------------------------- /tests/test_wheelfile.py: -------------------------------------------------------------------------------- 1 | import os 2 | from functools import partial 3 | from io import BytesIO 4 | from pathlib import Path 5 | from zipfile import ZIP_BZIP2, ZIP_DEFLATED, ZIP_STORED 6 | from zipfile import Path as ZipPath 7 | from zipfile import ZipFile, ZipInfo 8 | 9 | import pytest 10 | from packaging.version import Version 11 | 12 | from wheelfile import ( 13 | BadWheelFileError, 14 | ProhibitedWriteError, 15 | UnnamedDistributionError, 16 | WheelFile, 17 | ) 18 | 19 | 20 | def test_UnnamedDistributionError_is_BadWheelFileError(): 21 | assert issubclass(UnnamedDistributionError, BadWheelFileError) 22 | 23 | 24 | def test_BadWheelFileError_is_ValueError(): 25 | assert issubclass(BadWheelFileError, ValueError) 26 | 27 | 28 | def test_can_work_on_in_memory_bufs(buf): 29 | wf = WheelFile(buf, "w", distname="_", version="0") 30 | wf.close() 31 | assert buf.tell() != 0 32 | 33 | 34 | class TestWheelFileInit: 35 | 36 | def empty_wheel_bytes(self, name, version): 37 | buf = BytesIO() 38 | with WheelFile(buf, "w", name, version): 39 | pass 40 | 41 | @pytest.fixture 42 | def real_path(self, tmp_path): 43 | return tmp_path / "test-0-py2.py3-none-any.whl" 44 | 45 | def test_target_can_be_pathlib_path(self, real_path): 46 | WheelFile(real_path, "w").close() 47 | 48 | def test_target_can_be_str_path(self, real_path): 49 | path = str(real_path) 50 | WheelFile(path, "w").close() 51 | 52 | def test_target_can_be_binary_rwb_file_obj(self, real_path): 53 | file_obj = open(real_path, "wb+") 54 | WheelFile(file_obj, "w").close() 55 | 56 | # This one fails because refresh_record() reads from the zipfile. 57 | # The only way it could work is if the record calculation is performed on 58 | # the data passed directly to the method, not from the zipfile. 59 | @pytest.mark.skip # Because WheelFile.__del__ shows tb 60 | @pytest.mark.xfail 61 | def test_target_can_be_binary_wb_file_obj(self, real_path): 62 | file_obj = open(real_path, "wb") 63 | WheelFile(file_obj, "w").close() 64 | 65 | def test_on_bufs_x_mode_behaves_same_as_w(self): 66 | f1, f2 = BytesIO(), BytesIO() 67 | wf1 = WheelFile(f1, "x", distname="_", version="0") 68 | wf1.close() 69 | wf2 = WheelFile(f2, "w", distname="_", version="0") 70 | wf2.close() 71 | 72 | assert f1.getvalue() == f2.getvalue() 73 | 74 | def test_metadata_is_created(self, wf): 75 | assert wf.metadata is not None 76 | 77 | def test_created_metadata_contains_given_name(self, buf): 78 | wf = WheelFile(buf, "w", distname="given_name", version="0") 79 | assert wf.metadata.name == "given_name" 80 | 81 | def test_created_metadata_contains_given_version(self, buf): 82 | v = Version("1.2.3.4.5") 83 | wf = WheelFile(buf, "w", distname="_", version=v) 84 | assert wf.metadata.version == v 85 | 86 | def test_wheeldata_is_created(self, wf): 87 | assert wf.wheeldata is not None 88 | 89 | def test_record_is_created(self, wf): 90 | assert wf.record is not None 91 | 92 | def test_record_is_empty_after_creation(self, wf): 93 | assert str(wf.record) == "" 94 | 95 | def test_if_given_empty_distname_raises_ValueError(self, buf): 96 | with pytest.raises(ValueError): 97 | WheelFile(buf, "w", distname="", version="0") 98 | 99 | def test_if_given_distname_with_wrong_chars_raises_ValueError(self, buf): 100 | with pytest.raises(ValueError): 101 | WheelFile(buf, "w", distname="!@#%^&*", version="0") 102 | 103 | def test_wont_raise_on_distname_with_periods_and_underscores(self, buf): 104 | try: 105 | WheelFile(buf, "w", distname="_._._._", version="0") 106 | except ValueError: 107 | pytest.fail("Raised unexpectedly.") 108 | 109 | def test_if_given_empty_version_raises_ValueError(self, buf): 110 | with pytest.raises(ValueError): 111 | WheelFile(buf, "w", distname="_", version="") 112 | 113 | def test_if_given_bogus_version_raises_ValueError(self, buf): 114 | with pytest.raises(ValueError): 115 | WheelFile(buf, "w", distname="_", version="BOGUS") 116 | 117 | def test_default_language_tag_is_py3(self, wf): 118 | assert wf.language_tag == "py3" 119 | 120 | def test_default_abi_tag_is_none(self, wf): 121 | assert wf.abi_tag == "none" 122 | 123 | def test_default_platform_tag_is_any(self, wf): 124 | assert wf.platform_tag == "any" 125 | 126 | def test_if_given_build_number_passes_it_to_wheeldata(self, buf): 127 | build_tag = 123 128 | wf = WheelFile(buf, "w", distname="_", version="0", build_tag=build_tag) 129 | assert wf.wheeldata.build == build_tag 130 | 131 | def test_build_number_can_be_str(self, buf): 132 | build_tag = "123" 133 | wf = WheelFile(buf, "w", distname="_", version="0", build_tag=build_tag) 134 | assert wf.wheeldata.build == int(build_tag) 135 | 136 | def test_if_given_language_tag_passes_it_to_wheeldata_tags(self, buf): 137 | language_tag = "ip2" 138 | wf = WheelFile(buf, "w", distname="_", version="0", language_tag=language_tag) 139 | assert wf.wheeldata.tags == ["ip2-none-any"] 140 | 141 | def test_if_given_abi_tag_passes_it_to_wheeldata_tags(self, buf): 142 | abi_tag = "cp38d" 143 | wf = WheelFile(buf, "w", distname="_", version="0", abi_tag=abi_tag) 144 | assert wf.wheeldata.tags == ["py3-cp38d-any"] 145 | 146 | def test_if_given_platform_tag_passes_it_to_wheeldata_tags(self, buf): 147 | platform_tag = "linux_x84_64" 148 | wf = WheelFile(buf, "w", distname="_", version="0", platform_tag=platform_tag) 149 | assert wf.wheeldata.tags == ["py3-none-linux_x84_64"] 150 | 151 | def test_wheeldata_tag_defaults_to_py3_none_any(self, wf): 152 | assert wf.wheeldata.tags == ["py3-none-any"] 153 | 154 | def test_can_be_given_version_as_int(self, buf): 155 | with pytest.raises(TypeError): 156 | WheelFile(buf, mode="w", distname="wheel", version=1) 157 | 158 | def test_given_an_int_version_raises_type_error_on_buf(self, tmp_path): 159 | with pytest.raises(TypeError): 160 | WheelFile(tmp_path, mode="w", distname="wheel", version=1) 161 | 162 | @pytest.mark.skip 163 | def test_passes_zipfile_kwargs_to_zipfile(self, buf, zfarg): 164 | argument_to_pass_to_zipfile = zfarg 165 | WheelFile( 166 | buf, mode="w", distname="_", version="0", **argument_to_pass_to_zipfile 167 | ) 168 | 169 | def test_default_compression_method(self, wf): 170 | assert wf.zipfile.compression == ZIP_DEFLATED 171 | 172 | 173 | class TestWheelFileAttributes: 174 | 175 | def test_zipfile_attribute_returns_ZipFile(self, buf, wf): 176 | assert isinstance(wf.zipfile, ZipFile) 177 | 178 | def test_zipfile_attribute_is_read_only(self, buf): 179 | with pytest.raises(AttributeError): 180 | WheelFile(buf, "w", distname="_", version="0").zipfile = None 181 | 182 | def test_object_under_zipfile_uses_given_buf(self, wf, buf): 183 | assert wf.zipfile.fp is buf 184 | 185 | def test_filename_returns_buf_name(self, buf): 186 | buf.name = "random_name-0-py3-none-any.whl" 187 | wf = WheelFile(buf, "w", distname="_", version="0") 188 | assert wf.filename == buf.name 189 | 190 | def test_given_distname_is_stored_in_distname_attr(self, buf): 191 | distname = "random_name" 192 | wf = WheelFile(buf, "w", distname=distname, version="0") 193 | assert wf.distname == distname 194 | 195 | def test_given_version_is_stored_in_version_attr(self, buf): 196 | version = Version("1.2.3") 197 | wf = WheelFile(buf, "w", distname="_", version=version) 198 | assert wf.version == version 199 | 200 | def test_given_build_tag_is_stored_in_build_tag_attr(self, buf): 201 | build_tag = 123 202 | wf = WheelFile(buf, "w", distname="_", version="0", build_tag=build_tag) 203 | assert wf.build_tag == build_tag 204 | 205 | def test_given_str_build_tag_stores_int_in_build_tag_attr(self, buf): 206 | build_tag = "123" 207 | wf = WheelFile(buf, "w", distname="_", version="0", build_tag=build_tag) 208 | assert wf.build_tag == int(build_tag) 209 | 210 | def test_given_language_tag_is_stored_in_language_tag_attr(self, buf): 211 | language_tag = "cp3" 212 | wf = WheelFile(buf, "w", distname="_", version="0", language_tag=language_tag) 213 | assert wf.language_tag == language_tag 214 | 215 | def test_given_abi_tag_is_stored_in_abi_tag_attr(self, buf): 216 | abi_tag = "abi3" 217 | wf = WheelFile(buf, "w", distname="_", version="0", abi_tag=abi_tag) 218 | assert wf.abi_tag == abi_tag 219 | 220 | def test_given_platform_tag_is_stored_in_abi_tag_attr(self, buf): 221 | platform_tag = "win32" 222 | wf = WheelFile(buf, "w", distname="_", version="0", platform_tag=platform_tag) 223 | assert wf.platform_tag == platform_tag 224 | 225 | def test_distinfo_dirname(self, buf): 226 | wf = WheelFile(buf, distname="first.part", version="1.2.3", mode="w") 227 | assert wf.distinfo_dirname == "first_part-1.2.3.dist-info" 228 | 229 | def test_data_dirname(self, buf): 230 | wf = WheelFile(buf, distname="first.part", version="1.2.3", mode="w") 231 | assert wf.data_dirname == "first_part-1.2.3.data" 232 | 233 | 234 | class TestWheelFileClose: 235 | 236 | def test_closes_wheelfiles_zipfile(self, wf): 237 | wf.close() 238 | assert wf.zipfile.fp is None 239 | 240 | def test_adds_metadata_to_record(self, wf): 241 | wf.close() 242 | assert "_-0.dist-info/METADATA" in wf.record 243 | 244 | def test_adds_wheeldata_to_record(self, wf): 245 | wf.close() 246 | assert "_-0.dist-info/WHEEL" in wf.record 247 | 248 | def test_sets_closed(self, wf): 249 | assert not wf.closed 250 | wf.close() 251 | assert wf.closed 252 | 253 | def test_gets_called_on_del(self, wf): 254 | zf = wf.zipfile 255 | wf.__del__() 256 | assert zf.fp is None 257 | 258 | def test_calling_close_second_time_nothing(self, wf): 259 | wf.close() 260 | assert wf.closed 261 | wf.close() 262 | assert wf.closed 263 | 264 | @pytest.mark.xfail 265 | def test_refreshes_record(self, wf): 266 | path = "unrecorded/file/in/wheel" 267 | wf.zipfile.writestr(path, "_") 268 | assert path not in wf.record 269 | wf.close() 270 | assert path in wf.record 271 | 272 | 273 | class TestWheelFileContext: 274 | 275 | def test_is_not_closed_after_entry(self, buf): 276 | with WheelFile(buf, "w", distname="_", version="0") as wf: 277 | assert not wf.closed 278 | 279 | def test_is_closed_after_exit(self, buf): 280 | with WheelFile(buf, "w", distname="_", version="0") as wf: 281 | pass 282 | assert wf.closed 283 | 284 | 285 | class TestWheelFileWrites: 286 | 287 | @pytest.fixture 288 | def arcpath(self, wf): 289 | path = "/some/archive/path" 290 | return ZipPath(wf.zipfile, path) 291 | 292 | def test_writestr_writes_text(self, wf, arcpath): 293 | text = "Random text." 294 | wf.writestr(arcpath.at, text) 295 | assert arcpath.read_text() == text 296 | 297 | def test_writestr_writes_bytes(self, wf, arcpath): 298 | bytestr = b"random bytestr" 299 | wf.writestr(arcpath.at, bytestr) 300 | assert arcpath.read_bytes() == bytestr 301 | 302 | def test_writestr_written_file_to_record(self, wf, arcpath): 303 | assert arcpath.at not in wf.record 304 | wf.writestr(arcpath.at, "_") 305 | assert arcpath.at in wf.record 306 | 307 | def test_writestr_can_take_zipinfo(self, wf, arcpath): 308 | zi = ZipInfo(arcpath.at) 309 | wf.writestr(zi, "_") 310 | assert wf.zipfile.getinfo(arcpath.at) == zi 311 | 312 | def test_writestr_writes_path_to_record_as_is(self, wf): 313 | wf.writestr("/////this/should/be/stripped", "_") 314 | assert "/////this/should/be/stripped" in wf.record 315 | 316 | def test_write_adds_file_to_archive(self, wf, tmp_file): 317 | tmp_file.write_text("contents") 318 | wf.write(tmp_file) 319 | arc_file = ZipPath(wf.zipfile, str(tmp_file.name).lstrip("/")) 320 | 321 | assert arc_file.read_text() == tmp_file.read_text() 322 | 323 | def test_write_puts_files_at_arcname(self, wf, tmp_file): 324 | wf.write(tmp_file, arcname="arcname/path") 325 | assert "arcname/path" in wf.zipfile.namelist() 326 | 327 | def test_write_writes_proper_path_to_record(self, wf, tmp_file): 328 | wf.write(tmp_file, "/////this/should/be/stripped") 329 | assert "this/should/be/stripped" in wf.record 330 | 331 | def test_writes_preserve_mtime(self, wf, tmp_file): 332 | tmp_file.touch() 333 | 334 | # 1600000000 is September 2020 335 | ftime = 1600000000 336 | os.utime(tmp_file, (ftime, ftime)) 337 | 338 | wf.write(tmp_file, arcname="file") 339 | date_time = wf.zipfile.getinfo("file").date_time 340 | 341 | from datetime import datetime 342 | unix_timestamp = int(datetime(*date_time).timestamp()) 343 | 344 | assert unix_timestamp == ftime 345 | 346 | def test_write_has_resolve_arg(self, wf, tmp_file): 347 | wf.write(tmp_file, resolve=True) 348 | 349 | def test_write_data_has_resolve_arg(self, wf, tmp_file): 350 | wf.write_data(tmp_file, section="test", resolve=True) 351 | 352 | @pytest.fixture 353 | def spaghetti_path(self, tmp_path): 354 | (tmp_path / "s" / "p" / "a" / "g" / "h" / "e" / "t" / "t" / "i").mkdir( 355 | parents=True 356 | ) 357 | return tmp_path 358 | 359 | def test_write_resolves_paths(self, wf, spaghetti_path): 360 | path = spaghetti_path / "s/p/a/g/h/e/t/t/i/../../../t/t/i/file" 361 | path.touch() 362 | wf.write(path, resolve=True) 363 | assert wf.zipfile.namelist() == ["file"] 364 | 365 | def test_write_data_resolves_paths(self, wf, spaghetti_path): 366 | path = spaghetti_path / "s/p/a/g/h/e/t/t/i/../../../t/t/i/file" 367 | path.touch() 368 | wf.write_data(path, "section", resolve=True) 369 | data_path = wf.distname + "-" + str(wf.version) + ".data" 370 | assert wf.zipfile.namelist() == [data_path + "/section/file"] 371 | 372 | def test_write_doesnt_resolve_when_given_arcname(self, wf, tmp_file): 373 | wf.write(tmp_file, arcname="not_resolved", resolve=True) 374 | assert wf.zipfile.namelist() == ["not_resolved"] 375 | 376 | def test_write_data_doesnt_resolve_when_given_arcname(self, wf, tmp_file): 377 | wf.write_data(tmp_file, "section", arcname="not_resolved", resolve=True) 378 | data_path = wf.distname + "-" + str(wf.version) + ".data" 379 | assert wf.zipfile.namelist() == [data_path + "/section/not_resolved"] 380 | 381 | def test_write_distinfo_writes_to_the_right_arcname(self, wf, tmp_file): 382 | wf.write_distinfo(tmp_file) 383 | di_arcpath = wf.distname + "-" + str(wf.version) + ".dist-info" 384 | assert wf.zipfile.namelist() == [di_arcpath + "/" + tmp_file.name] 385 | 386 | def test_write_distinfo_resolve_arg(self, wf, tmp_file): 387 | wf.write_distinfo(tmp_file, resolve=False) 388 | di_arcpath = wf.distname + "-" + str(wf.version) + ".dist-info" 389 | assert wf.zipfile.namelist() == [di_arcpath + str(tmp_file)] 390 | 391 | def test_write_distinfo_recursive(self, wf, tmp_path): 392 | (tmp_path / "file").touch() 393 | wf.write_distinfo(tmp_path, skipdir=False, recursive=True) 394 | di_arcpath = wf.distname + "-" + str(wf.version) + ".dist-info" 395 | assert set(wf.zipfile.namelist()) == { 396 | di_arcpath + "/" + tmp_path.name + "/", 397 | di_arcpath + "/" + tmp_path.name + "/file", 398 | } 399 | 400 | def test_write_distinfo_non_recursive(self, wf, tmp_path): 401 | (tmp_path / "file").touch() 402 | wf.write_distinfo(tmp_path, skipdir=False, recursive=False) 403 | di_arcpath = wf.distname + "-" + str(wf.version) + ".dist-info" 404 | assert wf.zipfile.namelist() == [di_arcpath + "/" + tmp_path.name + "/"] 405 | 406 | def test_write_distinfo_arcpath(self, wf, tmp_file): 407 | wf.write_distinfo(tmp_file, arcname="custom_filename") 408 | di_arcpath = wf.distname + "-" + str(wf.version) + ".dist-info" 409 | assert wf.zipfile.namelist() == [di_arcpath + "/custom_filename"] 410 | 411 | @pytest.mark.parametrize("filename", ("WHEEL", "METADATA", "RECORD")) 412 | def test_write_distinfo_doesnt_permit_writing_metadata( 413 | self, wf, tmp_path, filename 414 | ): 415 | (tmp_path / filename).touch() 416 | with pytest.raises(ProhibitedWriteError): 417 | wf.write_distinfo(tmp_path / filename) 418 | 419 | def test_write_distinfo_doesnt_permit_empty_arcname(self, wf, tmp_file): 420 | with pytest.raises(ProhibitedWriteError): 421 | wf.write_distinfo(tmp_file, arcname="") 422 | 423 | @pytest.mark.xfail 424 | def test_write_distinfo_doesnt_permit_backing_out(self, wf, tmp_file): 425 | with pytest.raises(ValueError): 426 | wf.write_distinfo(tmp_file, arcname="../file") 427 | 428 | @pytest.mark.xfail 429 | @pytest.mark.parametrize("filename", ("WHEEL", "METADATA", "RECORD")) 430 | def test_write_doesnt_permit_writing_metadata(self, wf, tmp_path, filename): 431 | (tmp_path / filename).touch() 432 | with pytest.raises(ProhibitedWriteError): 433 | wf.write(tmp_path / filename) 434 | 435 | @pytest.mark.xfail 436 | @pytest.mark.parametrize("filename", ("WHEEL", "METADATA", "RECORD")) 437 | def test_writestr_doesnt_permit_writing_metadata(self, wf, filename): 438 | with pytest.raises(ProhibitedWriteError): 439 | wf.writestr(filename, b"") 440 | 441 | def test_writestr_distinfo(self, wf): 442 | arcname = "my_meta_file" 443 | data = b"my data" 444 | wf.writestr_distinfo(arcname, data) 445 | assert wf.zipfile.read(wf.distinfo_dirname + "/" + arcname) == data 446 | 447 | def test_writestr_distinfo_via_zipinfo(self, wf): 448 | arcname = "my_meta_file" 449 | data = b"my data" 450 | zi = ZipInfo(arcname) 451 | wf.writestr_distinfo(zi, data) 452 | assert wf.zipfile.read(wf.distinfo_dirname + "/" + arcname) == data 453 | 454 | @pytest.mark.parametrize("name", ("WHEEL", "METADATA", "RECORD")) 455 | def test_writestr_distinfo_doesnt_permit_writing_metadata(self, wf, name): 456 | with pytest.raises(ProhibitedWriteError): 457 | wf.writestr_distinfo(name, b"") 458 | 459 | @pytest.mark.parametrize("name", ("WHEEL", "METADATA", "RECORD")) 460 | def test_writestr_distinfo_doesnt_permit_writing_metadata_as_dirs(self, wf, name): 461 | with pytest.raises(ProhibitedWriteError): 462 | wf.writestr_distinfo(name + "/" + "file", b"") 463 | 464 | # TODO: also test write_data and write_distinfo 465 | # TODO: ALSO remember to test metadata names separately - they are not 466 | # inside the archive until `close()` is called, so it will not be detected. 467 | @pytest.mark.xfail 468 | def test_write_bails_on_writing_directories_over_files(self, wf, tmp_path): 469 | file_to_write = tmp_path / "file" 470 | file_to_write.touch() 471 | wf.write(file_to_write, "file") 472 | with pytest.raises(ProhibitedWriteError): 473 | wf.write(file_to_write, "file/or_is_it") 474 | 475 | # TODO: also test writestr_data and writestr_distinfo 476 | @pytest.mark.xfail 477 | def test_writestr_bails_on_writing_directories_over_files(self, wf): 478 | wf.writestr("file", b"") 479 | with pytest.raises(ProhibitedWriteError): 480 | wf.writestr("file/or_is_it", b"") 481 | 482 | 483 | def named_bytesio(name: str) -> BytesIO: 484 | bio = BytesIO() 485 | bio.name = str(name) 486 | return bio 487 | 488 | 489 | rwb_open = partial(open, mode="wb+") 490 | 491 | 492 | # TODO: when lazy mode is ready, test arguments priority over inference 493 | # TODO: when lazy mode is ready, test build number degeneration 494 | # TODO: when lazy mode is ready, test version degeneration 495 | @pytest.mark.parametrize("target_type", [str, Path, rwb_open, named_bytesio]) 496 | class TestWheelFileAttributeInference: 497 | """Tests how WheelFile infers metadata from the name of its target file""" 498 | 499 | def test_infers_from_given_path(self, tmp_path, target_type): 500 | path = target_type( 501 | tmp_path / "my_awesome.wheel-4.2.0-py38-cp38d-linux_x84_64.whl" 502 | ) 503 | wf = WheelFile(path, "w") 504 | assert ( 505 | wf.distname == "my_awesome.wheel" 506 | and str(wf.version) == "4.2.0" 507 | and wf.language_tag == "py38" 508 | and wf.abi_tag == "cp38d" 509 | and wf.platform_tag == "linux_x84_64" 510 | ) 511 | 512 | def test_infers_from_given_path_with_build_tag(self, tmp_path, target_type): 513 | path = target_type( 514 | tmp_path / "my_awesome.wheel-1.2.3.dev0-5-ip37-cp38d-win32.whl" 515 | ) 516 | wf = WheelFile(path, "w") 517 | assert ( 518 | wf.distname == "my_awesome.wheel" 519 | and str(wf.version) == "1.2.3.dev0" 520 | and wf.build_tag == 5 521 | and wf.language_tag == "ip37" 522 | and wf.abi_tag == "cp38d" 523 | and wf.platform_tag == "win32" 524 | ) 525 | 526 | def test_if_distname_part_is_empty_raises_UDE(self, tmp_path, target_type): 527 | path = target_type(tmp_path / "-4.2.0-py3-none-any.whl") 528 | with pytest.raises(UnnamedDistributionError): 529 | WheelFile(path, "w") 530 | 531 | def test_if_given_distname_only_raises_UDE(self, tmp_path, target_type): 532 | path = target_type(tmp_path / "my_awesome.wheel.whl") 533 | with pytest.raises(UnnamedDistributionError): 534 | WheelFile(path, "w") 535 | 536 | def test_if_version_part_is_empty_raises_UDE(self, tmp_path, target_type): 537 | path = target_type(tmp_path / "my_awesome.wheel--py3-none-any.whl") 538 | with pytest.raises(UnnamedDistributionError): 539 | WheelFile(path, "w") 540 | 541 | def test_if_bad_chars_in_distname_raises_VE(self, tmp_path, target_type): 542 | path = target_type(tmp_path / "my_@wesome.wheel-4.2.0-py3-none-any.whl") 543 | with pytest.raises(ValueError): 544 | WheelFile(path, "w") 545 | 546 | def test_if_invalid_version_raises_VE(self, tmp_path, target_type): 547 | path = target_type(tmp_path / "my_awesome.wheel-nice-py3-none-any.whl") 548 | with pytest.raises(ValueError): 549 | WheelFile(path, "w") 550 | 551 | 552 | def test_given_unnamed_buf_and_no_distname_raises_UDE(buf): 553 | with pytest.raises(UnnamedDistributionError): 554 | WheelFile(buf, "w", version="0") 555 | 556 | 557 | def test_given_unnamed_buf_and_no_version_raises_UDE(buf): 558 | with pytest.raises(UnnamedDistributionError): 559 | WheelFile(buf, "w", distname="_") 560 | 561 | 562 | class TestWheelFileDirectoryTarget: 563 | """Tests how WheelFile.__init__() behaves when given a directory""" 564 | 565 | def test_if_version_not_given_raises_ValueError(self, tmp_path): 566 | with pytest.raises(ValueError): 567 | WheelFile(tmp_path, "w", distname="my_dist") 568 | 569 | def test_if_distname_not_given_raises_ValueError(self, tmp_path): 570 | with pytest.raises(ValueError): 571 | WheelFile(tmp_path, "w", version="0") 572 | 573 | def test_given_directory_and_all_args__sets_filename(self, tmp_path): 574 | with WheelFile(tmp_path, "w", distname="my_dist", version="1.0.0") as wf: 575 | expected_name = ( 576 | "-".join( 577 | ( 578 | wf.distname, 579 | str(wf.version), 580 | wf.language_tag, 581 | wf.abi_tag, 582 | wf.platform_tag, 583 | ) 584 | ) 585 | + ".whl" 586 | ) 587 | assert wf.filename == str(tmp_path / expected_name) 588 | 589 | def test_given_no_target_assumes_curdir(self, tmp_path): 590 | old_path = Path.cwd() 591 | os.chdir(tmp_path) 592 | with WheelFile(mode="w", distname="my_dist", version="1.0.0") as wf: 593 | expected_name = ( 594 | "-".join( 595 | ( 596 | wf.distname, 597 | str(wf.version), 598 | wf.language_tag, 599 | wf.abi_tag, 600 | wf.platform_tag, 601 | ) 602 | ) 603 | + ".whl" 604 | ) 605 | assert wf.filename == str(Path("./") / expected_name) 606 | os.chdir(old_path) 607 | 608 | def test_given_no_target_creates_file_from_args(self, tmp_path): 609 | old_path = Path.cwd() 610 | os.chdir(tmp_path) 611 | with WheelFile( 612 | mode="w", 613 | distname="my_dist", 614 | version="1.2.alpha1", 615 | build_tag=123, 616 | language_tag="jp2", 617 | abi_tag="jre8", 618 | platform_tag="win32", 619 | ) as wf: 620 | expected_name = "my_dist-1.2a1-123-jp2-jre8-win32.whl" 621 | assert wf.filename == str(Path("./") / expected_name) 622 | os.chdir(old_path) 623 | 624 | 625 | class TestWheelFileDistDataWrite: 626 | 627 | def test_write__if_section_is_empty_raises_VE(self, wf): 628 | with pytest.raises(ValueError): 629 | wf.write_data("_", "") 630 | 631 | def test_write__if_section_contains_slashes_raises_VE(self, wf): 632 | with pytest.raises(ValueError): 633 | wf.write_data("_", "section/path/") 634 | 635 | @pytest.mark.parametrize("path_type", [str, Path]) 636 | def test_write__writes_given_str_path(self, wf, tmp_file, path_type): 637 | contents = "Contents of the file to write" 638 | expected_arcpath = f"_-0.data/section/{tmp_file.name}" 639 | tmp_file.write_text(contents) 640 | wf.write_data(path_type(tmp_file), "section") 641 | 642 | assert wf.zipfile.read(expected_arcpath) == tmp_file.read_bytes() 643 | 644 | def test_writestr__if_section_is_empty_raises_VE(self, wf): 645 | with pytest.raises(ValueError): 646 | wf.writestr_data("", "_", b"data") 647 | 648 | def test_writestr__if_section_contains_slashes_raises_VE(self, wf): 649 | with pytest.raises(ValueError): 650 | wf.writestr_data("section/path/", "_", "data") 651 | 652 | def test_writestr__writes_given_str_path(self, wf): 653 | contents = b"Contents of to write" 654 | filename = "file" 655 | expected_arcpath = f"_-0.data/section/{filename}" 656 | wf.writestr_data("section", filename, contents) 657 | 658 | assert wf.zipfile.read(expected_arcpath) == contents 659 | 660 | def test_mode_is_written_to_mode_attribute(self, wf): 661 | assert wf.mode == "w" 662 | 663 | 664 | class TestWheelFileRecursiveWrite: 665 | def test_write_has_recursive_arg(self, wf, tmp_path): 666 | wf.write(tmp_path, recursive=True) 667 | 668 | def test_recursive_write_does_not_break_on_files(self, wf, tmp_file): 669 | wf.write(tmp_file, recursive=True) 670 | 671 | def test_write_data_has_recursive_arg(self, wf, tmp_path): 672 | wf.write_data(tmp_path, "section", recursive=True) 673 | 674 | def test_recursive_write_data_does_not_break_on_files(self, wf, tmp_file): 675 | wf.write_data(tmp_file, "section", recursive=True) 676 | 677 | @pytest.fixture 678 | def path_tree(self, tmp_path): 679 | """The directory tree root is the first item in the list.""" 680 | d = tmp_path 681 | tree = [ 682 | d / "file", 683 | d / "empty_dir" / "", 684 | d / "dir_a" / "", 685 | d / "dir_a" / "subdir_a", 686 | d / "dir_a" / "subdir_a" / "1_file", 687 | d / "dir_a" / "subdir_a" / "2_file", 688 | d / "dir_b", 689 | d / "dir_b" / "subdir_b_1" / "", 690 | d / "dir_b" / "subdir_b_1" / "file", 691 | d / "dir_b" / "subdir_b_2" / "", 692 | d / "dir_b" / "subdir_b_2" / "file", 693 | ] 694 | 695 | for path in tree: 696 | if path.stem.endswith("file"): 697 | path.write_text("contents") 698 | else: 699 | path.mkdir() 700 | 701 | tree = [d] + tree 702 | 703 | return [str(p) + "/" if p.is_dir() else str(p) for p in tree] 704 | 705 | def test_write_recursive_writes_all_files_in_the_tree(self, wf, path_tree): 706 | directory = path_tree[0] 707 | wf.write(directory, recursive=True, resolve=False, skipdir=False) 708 | expected_tree = [pth.lstrip("/") for pth in path_tree] 709 | assert set(wf.zipfile.namelist()) == set(expected_tree) 710 | 711 | def test_write_recursive_writes_with_proper_arcname(self, wf, path_tree): 712 | directory = path_tree[0] 713 | custom_arcname = "something/different" 714 | wf.write(directory, arcname=custom_arcname, recursive=True) 715 | assert all(path.startswith(custom_arcname) for path in wf.zipfile.namelist()) 716 | 717 | def test_write_data_writes_recursively_when_asked(self, wf, path_tree): 718 | directory = path_tree[0] 719 | directory_name = os.path.basename(directory.rstrip("/")) 720 | archive_root = "_-0.data/test/" + directory_name + "/" 721 | 722 | wf.write_data(directory, section="test", skipdir=False, recursive=True) 723 | 724 | expected_tree = [archive_root + pth[len(directory) :] for pth in path_tree] 725 | assert set(wf.zipfile.namelist()) == set(expected_tree) 726 | 727 | def test_write_data_writes_non_recursively_when_asked(self, wf, path_tree): 728 | directory = path_tree[0] 729 | directory_name = os.path.basename(directory.rstrip("/")) 730 | archive_root = "_-0.data/test/" + directory_name + "/" 731 | 732 | wf.write_data(directory, section="test", skipdir=False, recursive=False) 733 | 734 | expected_tree = [archive_root] 735 | assert wf.zipfile.namelist() == expected_tree 736 | 737 | 738 | class TestWheelFileRefreshRecord: 739 | 740 | def test_silently_skips_directories(self, wf): 741 | wf.writestr("directory/", b"") 742 | wf.refresh_record("directory/") 743 | assert str(wf.record) == "" 744 | 745 | 746 | class TestWheelFileNameList: 747 | def test_after_init_is_empty(self, wf): 748 | assert wf.namelist() == [] 749 | 750 | def test_after_writing_contains_the_arcpath_of_written_file(self, wf): 751 | arcpath = "this/is/a/file" 752 | wf.writestr(arcpath, b"contents") 753 | assert wf.namelist() == [arcpath] 754 | 755 | @pytest.mark.parametrize("meta_file", ["METADATA", "RECORD", "WHEEL"]) 756 | def test_after_closing_does_not_contain_meta_files(self, wf, meta_file): 757 | wf.close() 758 | assert (wf.distinfo_dirname + "/" + meta_file) not in wf.namelist() 759 | 760 | 761 | class TestWheelFileInfoList: 762 | def test_after_init_is_empty(self, wf): 763 | assert wf.infolist() == [] 764 | 765 | def test_after_writing_contains_the_arcpath_of_written_file(self, wf): 766 | arcpath = "this/is/a/file" 767 | wf.writestr(arcpath, b"contents") 768 | infolist = wf.infolist() 769 | assert len(infolist) == 1 and infolist[0].filename == arcpath 770 | 771 | @pytest.mark.parametrize("meta_file", ["METADATA", "RECORD", "WHEEL"]) 772 | def test_after_closing_does_not_contain_meta_files(self, wf, meta_file): 773 | wf.close() 774 | infolist_arcpaths = [zi.filename for zi in wf.infolist()] 775 | assert (wf.distinfo_dirname + "/" + meta_file) not in infolist_arcpaths 776 | 777 | 778 | @pytest.mark.parametrize("metadata_name", ["METADATA", "RECORD", "WHEEL"]) 779 | def test_wheelfile_METADATA_FILENAMES(metadata_name): 780 | assert metadata_name in WheelFile.METADATA_FILENAMES 781 | 782 | 783 | class TestSkipDir: 784 | def test_write_skips_empty_dir_on_skipdir(self, wf, tmp_path): 785 | wf.write(tmp_path, recursive=False, skipdir=True) 786 | assert wf.namelist() == [] 787 | 788 | def test_write_data_skips_empty_dir_on_skipdir(self, wf, tmp_path): 789 | wf.write_data(tmp_path, section="_", recursive=False, skipdir=True) 790 | assert wf.namelist() == [] 791 | 792 | def test_write_distinfo_skips_empty_dir_on_skipdir(self, wf, tmp_path): 793 | wf.write_distinfo(tmp_path, recursive=False, skipdir=True) 794 | assert wf.namelist() == [] 795 | 796 | def test_write_doesnt_skip_dirs_if_skipdir_not_set(self, wf, tmp_path): 797 | wf.write(tmp_path, recursive=False, skipdir=False) 798 | expected_entry = str(tmp_path.name).lstrip("/") + "/" 799 | assert wf.namelist() == [expected_entry] 800 | 801 | def test_write_data_doesnt_skip_dirs_if_skipdir_not_set(self, wf, tmp_path): 802 | wf.write_data(tmp_path, section="_", recursive=False, skipdir=False) 803 | expected_entry = ( 804 | wf.data_dirname + "/" + "_/" + str(tmp_path.name).lstrip("/") + "/" 805 | ) 806 | assert wf.namelist() == [expected_entry] 807 | 808 | def test_write_distinfo_doesnt_skip_dirs_if_skipdir_not_set(self, wf, tmp_path): 809 | wf.write_distinfo(tmp_path, recursive=False, skipdir=False) 810 | expected_entry = ( 811 | wf.distinfo_dirname + "/" + str(tmp_path.name).lstrip("/") + "/" 812 | ) 813 | assert wf.namelist() == [expected_entry] 814 | 815 | 816 | class TestZipFileRelatedArgs: 817 | 818 | @pytest.fixture 819 | def wf(self, buf): 820 | wf = WheelFile( 821 | buf, "w", distname="_", version="0", compression=ZIP_STORED, compresslevel=1 822 | ) 823 | yield wf 824 | wf.close() 825 | 826 | def test_passes_compression_arg_to_zipfile(self, buf): 827 | wf = WheelFile(buf, mode="w", distname="_", version="0", compression=ZIP_BZIP2) 828 | assert wf.zipfile.compression == ZIP_BZIP2 829 | 830 | def test_passes_allowZip64_arg_to_zipfile(self, buf): 831 | wf = WheelFile(buf, mode="w", distname="_", version="0", allowZip64=False) 832 | # ZipFile.open trips when allowZip64 is forced in a zipfile that does 833 | # not allow it. 834 | # 835 | # Exception message: 836 | # "force_zip64 is True, but allowZip64 was False when opening the ZIP 837 | # file." 838 | with pytest.raises(ValueError, match="allowZip64 was False"): 839 | assert wf.zipfile.open("file", mode="w", force_zip64=True) 840 | 841 | def test_passes_compresslevel_arg_to_zipfile(self, buf): 842 | wf = WheelFile(buf, mode="w", distname="_", version="0", compresslevel=7) 843 | assert wf.zipfile.compresslevel == 7 844 | 845 | def test_passes_strict_timestamps_arg_to_zipfile(self, buf, tmp_file): 846 | wf = WheelFile( 847 | buf, mode="w", distname="_", version="0", strict_timestamps=False 848 | ) 849 | # strict_timestamps will be propagated into ZipInfo objects created by 850 | # ZipFile. 851 | # Given very old timestamp, ZipInfo will set itself to 01-01-1980 852 | os.utime(tmp_file, (10000000, 100000000)) 853 | wf.write(tmp_file, resolve=False) 854 | zinfo = wf.zipfile.getinfo(str(tmp_file).lstrip("/")) 855 | assert zinfo.date_time == (1980, 1, 1, 0, 0, 0) 856 | 857 | def test_writestr_sets_the_right_compress_type(self, wf): 858 | arcname = "file" 859 | wf.writestr(arcname, b"_", compress_type=ZIP_BZIP2) 860 | assert wf.zipfile.getinfo(arcname).compress_type == ZIP_BZIP2 861 | 862 | def test_writestr_compress_type_overrides_zinfo(self, wf): 863 | zi = ZipInfo("_") 864 | zi.compress_type = ZIP_DEFLATED 865 | wf.writestr(zi, b"_", compress_type=ZIP_BZIP2) 866 | assert wf.zipfile.getinfo(zi.filename).compress_type == ZIP_BZIP2 867 | 868 | def test_writestr_data_sets_the_right_compress_type(self, wf): 869 | arcname = "file" 870 | wf.writestr_data("_", arcname, b"_", compress_type=ZIP_BZIP2) 871 | arcpath = wf.data_dirname + "/_/" + arcname 872 | assert wf.zipfile.getinfo(arcpath).compress_type == ZIP_BZIP2 873 | 874 | def test_writestr_data_compress_type_overrides_zinfo(self, wf): 875 | zi = ZipInfo("_") 876 | zi.compress_type = ZIP_DEFLATED 877 | wf.writestr_data("_", zi, b"_", compress_type=ZIP_BZIP2) 878 | arcpath = wf.data_dirname + "/_/" + zi.filename 879 | assert wf.zipfile.getinfo(arcpath).compress_type == ZIP_BZIP2 880 | 881 | def test_writestr_distinfo_sets_the_right_compress_type(self, wf): 882 | arcname = "file" 883 | wf.writestr_distinfo(arcname, b"_", compress_type=ZIP_BZIP2) 884 | arcpath = wf.distinfo_dirname + "/" + arcname 885 | assert wf.zipfile.getinfo(arcpath).compress_type == ZIP_BZIP2 886 | 887 | def test_writestr_distinfo_compress_type_overrides_zinfo(self, wf): 888 | zi = ZipInfo("_") 889 | zi.compress_type = ZIP_DEFLATED 890 | wf.writestr_distinfo(zi, b"_", compress_type=ZIP_BZIP2) 891 | arcpath = wf.distinfo_dirname + "/" + zi.filename 892 | assert wf.zipfile.getinfo(arcpath).compress_type == ZIP_BZIP2 893 | 894 | def test_write_sets_the_right_compress_type(self, wf, tmp_file): 895 | wf.write(tmp_file, compress_type=ZIP_BZIP2) 896 | assert wf.zipfile.getinfo(tmp_file.name).compress_type == ZIP_BZIP2 897 | 898 | def test_write_data_sets_the_right_compress_type(self, wf, tmp_file): 899 | wf.write_data(tmp_file, "_", compress_type=ZIP_BZIP2) 900 | arcpath = wf.data_dirname + "/_/" + tmp_file.name 901 | assert wf.zipfile.getinfo(arcpath).compress_type == ZIP_BZIP2 902 | 903 | def test_write_distinfo_sets_the_right_compress_type(self, wf, tmp_file): 904 | wf.write_distinfo(tmp_file, compress_type=ZIP_BZIP2) 905 | arcpath = wf.distinfo_dirname + "/" + tmp_file.name 906 | assert wf.zipfile.getinfo(arcpath).compress_type == ZIP_BZIP2 907 | 908 | def test_writestr_sets_the_right_compresslevel(self, wf): 909 | arcname = "file" 910 | wf.writestr(arcname, b"_", compresslevel=7) 911 | assert wf.zipfile.getinfo(arcname)._compresslevel == 7 912 | 913 | def test_writestr_compresslevel_overrides_zinfo(self, wf): 914 | zi = ZipInfo("_") 915 | zi._compresslevel = 3 916 | wf.writestr(zi, b"_", compresslevel=7) 917 | assert wf.zipfile.getinfo(zi.filename)._compresslevel == 7 918 | 919 | def test_writestr_data_sets_the_right_compresslevel(self, wf): 920 | arcname = "file" 921 | wf.writestr_data("_", arcname, b"_", compresslevel=7) 922 | arcpath = wf.data_dirname + "/_/" + arcname 923 | assert wf.zipfile.getinfo(arcpath)._compresslevel == 7 924 | 925 | def test_writestr_data_compresslevel_overrides_zinfo(self, wf): 926 | zi = ZipInfo("_") 927 | zi._compresslevel = 3 928 | wf.writestr_data("_", zi, b"_", compresslevel=7) 929 | arcpath = wf.data_dirname + "/_/" + zi.filename 930 | assert wf.zipfile.getinfo(arcpath)._compresslevel == 7 931 | 932 | def test_writestr_distinfo_sets_the_right_compresslevel(self, wf): 933 | arcname = "file" 934 | wf.writestr_distinfo(arcname, b"_", compresslevel=7) 935 | arcpath = wf.distinfo_dirname + "/" + arcname 936 | assert wf.zipfile.getinfo(arcpath)._compresslevel == 7 937 | 938 | def test_writestr_distinfo_compresslevel_overrides_zinfo(self, wf): 939 | zi = ZipInfo("_") 940 | zi._compresslevel = 3 941 | wf.writestr_distinfo(zi, b"_", compresslevel=7) 942 | arcpath = wf.distinfo_dirname + "/" + zi.filename 943 | assert wf.zipfile.getinfo(arcpath)._compresslevel == 7 944 | 945 | def test_write_sets_the_right_compresslevel(self, wf, tmp_file): 946 | wf.write(tmp_file, compresslevel=7) 947 | assert wf.zipfile.getinfo(tmp_file.name)._compresslevel == 7 948 | 949 | def test_write_data_sets_the_right_compresslevel(self, wf, tmp_file): 950 | wf.write_data(tmp_file, "_", compresslevel=7) 951 | arcpath = wf.data_dirname + "/_/" + tmp_file.name 952 | assert wf.zipfile.getinfo(arcpath)._compresslevel == 7 953 | 954 | def test_write_distinfo_sets_the_right_compresslevel(self, wf, tmp_file): 955 | wf.write_distinfo(tmp_file, compresslevel=7) 956 | arcpath = wf.distinfo_dirname + "/" + tmp_file.name 957 | assert wf.zipfile.getinfo(arcpath)._compresslevel == 7 958 | 959 | def test_write_default_compress_type_is_deflate(self, buf, tmp_file): 960 | wf = WheelFile(buf, "w", distname="_", version="0") 961 | wf.write(tmp_file) 962 | assert wf.infolist()[0].compress_type == ZIP_DEFLATED 963 | 964 | def test_write_data_default_compress_type_is_deflate(self, buf, tmp_file): 965 | wf = WheelFile(buf, "w", distname="_", version="0") 966 | wf.write_data(tmp_file, "section") 967 | assert wf.infolist()[0].compress_type == ZIP_DEFLATED 968 | 969 | def test_write_distinfo_default_compress_type_is_deflate(self, buf, tmp_file): 970 | wf = WheelFile(buf, "w", distname="_", version="0") 971 | wf.write_distinfo(tmp_file) 972 | assert wf.infolist()[0].compress_type == ZIP_DEFLATED 973 | 974 | def test_write_default_compress_type_is_from_init(self, buf, tmp_file): 975 | wf = WheelFile(buf, "w", distname="_", version="0", compression=ZIP_BZIP2) 976 | wf.write(tmp_file) 977 | assert wf.infolist()[0].compress_type == ZIP_BZIP2 978 | 979 | def test_write_data_default_compress_type_is_from_init(self, buf, tmp_file): 980 | wf = WheelFile(buf, "w", distname="_", version="0", compression=ZIP_BZIP2) 981 | wf.write_data(tmp_file, "section") 982 | assert wf.infolist()[0].compress_type == ZIP_BZIP2 983 | 984 | def test_write_distinfo_default_compress_type_is_from_init(self, buf, tmp_file): 985 | wf = WheelFile(buf, "w", distname="_", version="0", compression=ZIP_BZIP2) 986 | wf.write_distinfo(tmp_file) 987 | assert wf.infolist()[0].compress_type == ZIP_BZIP2 988 | 989 | def test_write_default_compresslevel_is_none(self, buf, tmp_file): 990 | wf = WheelFile(buf, "w", distname="_", version="0") 991 | wf.write(tmp_file) 992 | assert wf.infolist()[0]._compresslevel is None 993 | 994 | def test_write_data_default_compresslevel_is_none(self, buf, tmp_file): 995 | wf = WheelFile(buf, "w", distname="_", version="0") 996 | wf.write_data(tmp_file, "section") 997 | assert wf.infolist()[0]._compresslevel is None 998 | 999 | def test_write_distinfo_default_compresslevel_is_none(self, buf, tmp_file): 1000 | wf = WheelFile(buf, "w", distname="_", version="0") 1001 | wf.write_distinfo(tmp_file) 1002 | assert wf.infolist()[0]._compresslevel is None 1003 | 1004 | def test_write_default_compresslevel_is_from_init(self, buf, tmp_file): 1005 | wf = WheelFile(buf, "w", distname="_", version="0", compresslevel=9) 1006 | wf.write(tmp_file) 1007 | assert wf.infolist()[0]._compresslevel == 9 1008 | 1009 | def test_write_data_default_compresslevel_is_from_init(self, buf, tmp_file): 1010 | wf = WheelFile(buf, "w", distname="_", version="0", compresslevel=9) 1011 | wf.write_data(tmp_file, "section") 1012 | assert wf.infolist()[0]._compresslevel == 9 1013 | 1014 | def test_write_distinfo_default_compresslevel_is_from_init(self, buf, tmp_file): 1015 | wf = WheelFile(buf, "w", distname="_", version="0", compresslevel=9) 1016 | wf.write_distinfo(tmp_file) 1017 | assert wf.infolist()[0]._compresslevel == 9 1018 | 1019 | def test_writestr_default_compress_type_is_deflate(self, buf): 1020 | wf = WheelFile(buf, "w", distname="_", version="0") 1021 | wf.writestr("file", b"data") 1022 | assert wf.infolist()[0].compress_type == ZIP_DEFLATED 1023 | 1024 | def test_writestr_data_default_compress_type_is_deflate(self, buf): 1025 | wf = WheelFile(buf, "w", distname="_", version="0") 1026 | wf.writestr_data("section", "file", b"data") 1027 | assert wf.infolist()[0].compress_type == ZIP_DEFLATED 1028 | 1029 | def test_writestr_distinfo_default_compress_type_is_deflate(self, buf): 1030 | wf = WheelFile(buf, "w", distname="_", version="0") 1031 | wf.writestr_distinfo("file", b"data") 1032 | assert wf.infolist()[0].compress_type == ZIP_DEFLATED 1033 | 1034 | def test_writestr_default_compress_type_is_from_init(self, buf): 1035 | wf = WheelFile(buf, "w", distname="_", version="0", compression=ZIP_BZIP2) 1036 | wf.writestr("file", b"data") 1037 | assert wf.infolist()[0].compress_type == ZIP_BZIP2 1038 | 1039 | def test_writestr_data_default_compress_type_is_from_init(self, buf): 1040 | wf = WheelFile(buf, "w", distname="_", version="0", compression=ZIP_BZIP2) 1041 | wf.writestr_data("section", "file", b"data") 1042 | assert wf.infolist()[0].compress_type == ZIP_BZIP2 1043 | 1044 | def test_writestr_distinfo_default_compress_type_is_from_init(self, buf): 1045 | wf = WheelFile(buf, "w", distname="_", version="0", compression=ZIP_BZIP2) 1046 | wf.writestr_distinfo("file", b"data") 1047 | assert wf.infolist()[0].compress_type == ZIP_BZIP2 1048 | 1049 | def test_writestr_default_compresslevel_is_none(self, buf): 1050 | wf = WheelFile(buf, "w", distname="_", version="0") 1051 | wf.writestr("file", b"data") 1052 | assert wf.infolist()[0]._compresslevel is None 1053 | 1054 | def test_writestr_data_default_compresslevel_is_none(self, buf): 1055 | wf = WheelFile(buf, "w", distname="_", version="0") 1056 | wf.writestr_data("section", "file", b"data") 1057 | assert wf.infolist()[0]._compresslevel is None 1058 | 1059 | def test_writestr_distinfo_default_compresslevel_is_none(self, buf): 1060 | wf = WheelFile(buf, "w", distname="_", version="0") 1061 | wf.writestr_distinfo("file", b"data") 1062 | assert wf.infolist()[0]._compresslevel is None 1063 | 1064 | def test_writestr_default_compresslevel_is_from_init(self, buf): 1065 | wf = WheelFile(buf, "w", distname="_", version="0", compresslevel=9) 1066 | wf.writestr("file", b"data") 1067 | assert wf.infolist()[0]._compresslevel == 9 1068 | 1069 | def test_writestr_data_default_compresslevel_is_from_init(self, buf): 1070 | wf = WheelFile(buf, "w", distname="_", version="0", compresslevel=9) 1071 | wf.writestr_data("section", "file", b"data") 1072 | assert wf.infolist()[0]._compresslevel == 9 1073 | 1074 | def test_writestr_distinfo_default_compresslevel_is_from_init(self, buf): 1075 | wf = WheelFile(buf, "w", distname="_", version="0", compresslevel=9) 1076 | wf.writestr_distinfo("file", b"data") 1077 | assert wf.infolist()[0]._compresslevel == 9 1078 | 1079 | 1080 | class TestZipinfoAttributePreserval: 1081 | 1082 | preserved_fields = pytest.mark.parametrize( 1083 | "field, value", 1084 | [ 1085 | ("date_time", (2000, 1, 2, 3, 4, 2)), 1086 | ("compress_type", ZIP_BZIP2), 1087 | ("comment", b"Wubba lubba dub dub"), 1088 | ("extra", bytes([0x00, 0x00, 0x04, 0x00] + [0xFF] * 4)), 1089 | ("create_system", 4), 1090 | ("create_version", 31), 1091 | ("extract_version", 42), 1092 | ("internal_attr", 0x02), 1093 | ("external_attr", 0x02), 1094 | # Failing / impossible: 1095 | # ZIP stores timestamps with two seconds of granularity 1096 | # ("date_time", (2000, 1, 2, 3, 4, 1)), 1097 | # Not preservable without changing other values 1098 | # ("flag_bits", 0xFFFFFF), 1099 | # Not supported by Python's zipfile 1100 | # ("volume", 0x01), 1101 | ], 1102 | ) 1103 | 1104 | @preserved_fields 1105 | def test_writestr_propagates_zipinfo_fields(self, field, value, wf, buf): 1106 | arcpath = "some/archive/path" 1107 | zi = ZipInfo(arcpath) 1108 | setattr(zi, field, value) 1109 | 1110 | wf.writestr(zi, "_") 1111 | wf.close() 1112 | 1113 | with WheelFile(buf, distname="_", version="0") as wf: 1114 | assert getattr(wf.zipfile.getinfo(arcpath), field) == value 1115 | 1116 | @preserved_fields 1117 | def test_writestr_data_propagates_zipinfo_fields(self, field, value, wf, buf): 1118 | data_path = "some/data" 1119 | section = "section" 1120 | zi = ZipInfo(data_path) 1121 | setattr(zi, field, value) 1122 | 1123 | wf.writestr_data(section, zi, "_") 1124 | wf.close() 1125 | 1126 | arcpath = wf.data_dirname + "/" + section + "/" + data_path 1127 | 1128 | with WheelFile(buf, distname="_", version="0") as wf: 1129 | assert getattr(wf.zipfile.getinfo(arcpath), field) == value 1130 | 1131 | @preserved_fields 1132 | def test_writestr_distinfo_propagates_zipinfo_fields(self, field, value, wf, buf): 1133 | data_path = "some/metadata" 1134 | zi = ZipInfo(data_path) 1135 | setattr(zi, field, value) 1136 | 1137 | wf.writestr_distinfo(zi, "_") 1138 | wf.close() 1139 | 1140 | arcpath = wf.distinfo_dirname + "/" + data_path 1141 | 1142 | with WheelFile(buf, distname="_", version="0") as wf: 1143 | assert getattr(wf.zipfile.getinfo(arcpath), field) == value 1144 | -------------------------------------------------------------------------------- /tests/test_wheelfile_cloning.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | from zipfile import ZIP_BZIP2, ZIP_DEFLATED, ZIP_LZMA, ZIP_STORED, ZipInfo 4 | 5 | import pytest 6 | from packaging.version import Version 7 | 8 | from wheelfile import MetaData, WheelFile, __version__ 9 | 10 | from .test_metas import TestMetadata as MetaDataTests 11 | 12 | 13 | @pytest.fixture 14 | def wf(): 15 | buf = io.BytesIO() # Cannot be the same as the one from buf fixture 16 | wf = WheelFile( 17 | buf, 18 | "w", 19 | distname="dist", 20 | version="123", 21 | build_tag="321", 22 | language_tag="lang", 23 | abi_tag="abi", 24 | platform_tag="win32", 25 | compression=ZIP_STORED, 26 | compresslevel=1, 27 | ) 28 | yield wf 29 | wf.close() 30 | 31 | 32 | class TestCloneInit: 33 | 34 | def test_returns_wheelfile(self, wf, buf): 35 | cwf = WheelFile.from_wheelfile(wf, buf) 36 | assert isinstance(cwf, WheelFile) 37 | 38 | def test_is_open_after_cloning(self, wf, buf): 39 | wf.close() 40 | cwf = WheelFile.from_wheelfile(wf, buf) 41 | assert cwf.closed is False 42 | 43 | def test_mode_is_set_to_w_by_default(self, wf, buf): 44 | cwf = WheelFile.from_wheelfile(wf, buf) 45 | assert cwf.mode == "w" 46 | 47 | @pytest.mark.parametrize("disallowed_mode", ["r", "rl"]) 48 | def test_read_mode_is_not_allowed(self, wf, buf, disallowed_mode): 49 | with pytest.raises(ValueError): 50 | WheelFile.from_wheelfile(wf, buf, mode=disallowed_mode) 51 | 52 | 53 | class TestUnspecifiedArgs: 54 | 55 | def test_copies_distname(self, wf, buf): 56 | cwf = WheelFile.from_wheelfile(wf, buf) 57 | assert cwf.distname == wf.distname 58 | 59 | def test_copies_version(self, wf, buf): 60 | cwf = WheelFile.from_wheelfile(wf, buf) 61 | assert cwf.version == wf.version 62 | 63 | def test_copies_build_tag(self, wf, buf): 64 | cwf = WheelFile.from_wheelfile(wf, buf) 65 | assert cwf.version == wf.version 66 | 67 | def test_copies_language(self, wf, buf): 68 | cwf = WheelFile.from_wheelfile(wf, buf) 69 | assert cwf.language_tag == wf.language_tag 70 | 71 | def test_copies_abi(self, wf, buf): 72 | cwf = WheelFile.from_wheelfile(wf, buf) 73 | assert cwf.abi_tag == wf.abi_tag 74 | 75 | def test_copies_platform(self, wf, buf): 76 | cwf = WheelFile.from_wheelfile(wf, buf) 77 | assert cwf.platform_tag == wf.platform_tag 78 | 79 | def test_none_build_tag_sets_default(self, wf, buf): 80 | cwf = WheelFile.from_wheelfile( 81 | wf, buf, distname="_", version="0", build_tag=None 82 | ) 83 | assert cwf.build_tag is None 84 | 85 | def test_none_language_tag_sets_default(self, wf, buf): 86 | cwf = WheelFile.from_wheelfile( 87 | wf, buf, distname="_", version="0", language_tag=None 88 | ) 89 | assert cwf.language_tag == "py3" 90 | 91 | def test_none_abi_tag_sets_default(self, wf, buf): 92 | cwf = WheelFile.from_wheelfile(wf, buf, distname="_", version="0", abi_tag=None) 93 | assert cwf.abi_tag == "none" 94 | 95 | def test_none_platform_tag_sets_default(self, wf, buf): 96 | cwf = WheelFile.from_wheelfile( 97 | wf, buf, distname="_", version="0", platform_tag=None 98 | ) 99 | assert cwf.platform_tag == "any" 100 | 101 | 102 | class TestZipFileRelatedArgs: 103 | 104 | # These tests are more or less the same tests as those in test_wheelfile. 105 | 106 | def test_passes_compression_arg_to_zipfile(self, wf, buf): 107 | cwf = WheelFile.from_wheelfile( 108 | wf, buf, distname="_", version="0", compression=ZIP_BZIP2 109 | ) 110 | assert cwf.zipfile.compression == ZIP_BZIP2 111 | 112 | def test_passes_allowzip64_arg_to_zipfile(self, wf, buf, tmp_file): 113 | cwf = WheelFile.from_wheelfile( 114 | wf, buf, distname="_", version="0", allowZip64=False 115 | ) 116 | # ZipFile.open trips when allowZip64 is forced in a zipfile that does 117 | # not allow it. 118 | # 119 | # Exception message: 120 | # "force_zip64 is True, but allowZip64 was False when opening the ZIP 121 | # file." 122 | with pytest.raises(ValueError, match="allowZip64 was False"): 123 | cwf.zipfile.open("file", mode="w", force_zip64=True) 124 | 125 | def test_passes_compresslevel_arg_to_init(self, wf, buf): 126 | cwf = WheelFile.from_wheelfile( 127 | wf, buf, distname="_", version="0", compresslevel=7 128 | ) 129 | assert cwf.zipfile.compresslevel == 7 130 | 131 | def test_passes_strict_timestamps_arg_to_zipfile(self, wf, buf, tmp_file): 132 | cwf = WheelFile.from_wheelfile( 133 | wf, buf, distname="_", version="0", strict_timestamps=False 134 | ) 135 | # strict_timestamps will be propagated into ZipInfo objects created by 136 | # ZipFile. 137 | # Given very old timestamp, ZipInfo will set itself to 01-01-1980 138 | os.utime(tmp_file, (10000000, 100000000)) 139 | cwf.write(tmp_file, resolve=False) 140 | zinfo = cwf.zipfile.getinfo(str(tmp_file).lstrip("/")) 141 | assert zinfo.date_time == (1980, 1, 1, 0, 0, 0) 142 | 143 | def test_when_not_given_uses_default_compression(self, wf, buf): 144 | cwf = WheelFile.from_wheelfile(wf, buf, distname="_", version="0") 145 | assert cwf.zipfile.compression == ZIP_DEFLATED 146 | 147 | def test_when_not_given_uses_default_allowzip64_flag(self, wf, buf, tmp_file): 148 | cwf = WheelFile.from_wheelfile(wf, buf, distname="_", version="0") 149 | # ZipFile.open trips when allowZip64 is forced in a zipfile that does 150 | # not allow it. Here it should have allowZip64 == True, since True is 151 | # the zipfile.ZipFile default for this argument 152 | assert cwf.zipfile.open("file", mode="w", force_zip64=True) 153 | 154 | def test_when_not_given_uses_default_compresslevel(self, wf, buf): 155 | cwf = WheelFile.from_wheelfile(wf, buf, distname="_", version="0") 156 | assert cwf.zipfile.compresslevel is None 157 | 158 | def test_when_not_given_uses_default_timestamps_flag(self, wf, buf, tmp_file): 159 | 160 | cwf = WheelFile.from_wheelfile(wf, buf, distname="_", version="0") 161 | # Given very old timestamp, if strict_timestamps=True (which is the 162 | # default of zipfile.ZipFile), writing a file with mtime before 1980 163 | # will raise a ValueError: 164 | # 165 | # "ValueError: ZIP does not support timestamps before 1980" 166 | # 167 | os.utime(tmp_file, (10000000, 100000000)) 168 | with pytest.raises(ValueError, match="ZIP does not support timestamps"): 169 | cwf.write(tmp_file, resolve=False) 170 | 171 | 172 | class TestCloneTypes: 173 | 174 | def test_buf_to_buf(self, wf, buf): 175 | cwf = WheelFile.from_wheelfile(wf, buf) 176 | assert cwf.zipfile.fp is buf 177 | 178 | def test_buf_to_file_obj(self, wf, tmp_path): 179 | tmp_file = tmp_path / "_-0-py3-none-any.whl" 180 | with open(tmp_file, mode="bw+") as f: 181 | with WheelFile.from_wheelfile(wf, f) as cwf: 182 | assert cwf.zipfile.fp is f 183 | 184 | def test_buf_to_dir(self, wf, tmp_path): 185 | cwf = WheelFile.from_wheelfile(wf, tmp_path) 186 | expected_name = wf.filename 187 | assert cwf.filename == str(tmp_path / expected_name) 188 | 189 | def test_buf_to_path(self, wf, tmp_path): 190 | tmp_file = tmp_path / "_-0-py3-none-any.whl" 191 | cwf = WheelFile.from_wheelfile(wf, tmp_file) 192 | assert cwf.filename == str(tmp_file) 193 | 194 | def test_buf_to_buf_changed_tags(self, wf, buf): 195 | cwf = WheelFile.from_wheelfile( 196 | wf, 197 | buf, 198 | distname="copy", 199 | version="123", 200 | build_tag="321", 201 | language_tag="rustpy4", 202 | abi_tag="someabi2000", 203 | platform_tag="ibm500", 204 | ) 205 | assert cwf.filename == "copy-123-321-rustpy4-someabi2000-ibm500.whl" 206 | 207 | 208 | class TestRegularFilesCloning: 209 | 210 | def test_files_are_recreated(self, wf, buf): 211 | wf.writestr("file1", "") 212 | wf.writestr("file2", "") 213 | wf.writestr("file3", "") 214 | 215 | with WheelFile.from_wheelfile(wf, buf) as cwf: 216 | assert cwf.namelist() == wf.namelist() 217 | 218 | def test_data_is_copied(self, wf, buf): 219 | archive = {"file1": b"data1", "file2": b"data2", "file3": b"data3"} 220 | 221 | for arcname, data in archive.items(): 222 | wf.writestr(arcname, data) 223 | 224 | with WheelFile.from_wheelfile(wf, buf) as cwf: 225 | for arcname, data in archive.items(): 226 | assert cwf.zipfile.read(arcname) == data 227 | 228 | def test_substitutes_compress_type_if_passed(self, wf, buf): 229 | wf.writestr("file1", "", compress_type=ZIP_BZIP2) 230 | new_compression = ZIP_LZMA 231 | 232 | with WheelFile.from_wheelfile(wf, buf, compression=new_compression) as cwf: 233 | assert cwf.zipfile.infolist()[0].compress_type == new_compression 234 | 235 | def test_preserves_compress_type_if_not_passed(self, wf, buf): 236 | old_compression = ZIP_BZIP2 237 | wf.writestr("file1", "", compress_type=old_compression) 238 | 239 | with WheelFile.from_wheelfile(wf, buf) as cwf: 240 | assert cwf.zipfile.infolist()[0].compress_type == old_compression 241 | 242 | def test_substitutes_compresslevel_if_passed(self, wf, buf): 243 | wf.writestr("file1", "", compress_type=ZIP_BZIP2, compresslevel=5) 244 | new_compresslevel = 7 245 | 246 | with WheelFile.from_wheelfile( 247 | wf, buf, compression=ZIP_LZMA, compresslevel=new_compresslevel 248 | ) as cwf: 249 | assert cwf.zipfile.infolist()[0]._compresslevel == new_compresslevel 250 | 251 | def test_preserves_compresslevel_if_not_passed(self, wf, buf): 252 | old_compresslevel = 7 253 | wf.writestr( 254 | "file1", "", compress_type=ZIP_BZIP2, compresslevel=old_compresslevel 255 | ) 256 | 257 | with WheelFile.from_wheelfile(wf, buf) as cwf: 258 | assert cwf.zipfile.infolist()[0]._compresslevel == old_compresslevel 259 | 260 | PRESERVED_ZIPINFO_ATTRS = [ 261 | "date_time", 262 | "compress_type", 263 | "_compresslevel", 264 | "comment", 265 | "extra", 266 | "create_system", 267 | "create_version", 268 | "extract_version", 269 | "volume", 270 | "internal_attr", 271 | "external_attr", 272 | ] 273 | 274 | def custom_zipinfo(self): 275 | zf = ZipInfo("file", date_time=(1984, 6, 8, 1, 2, 3)) 276 | zf.compress_type = ZIP_BZIP2 277 | zf.comment = b"comment" 278 | zf.extra = b"extra" 279 | zf.create_system = 2 280 | zf.create_version = 50 281 | zf.extract_version = 60 282 | zf.volume = 7 283 | zf.internal_attr = 123 284 | zf.external_attr = 321 285 | return zf 286 | 287 | @pytest.mark.parametrize("attr", PRESERVED_ZIPINFO_ATTRS) 288 | def test_zip_attributes_are_preserved_writestr(self, wf, buf, attr): 289 | zf = self.custom_zipinfo() 290 | wf.writestr(zf, b"data") 291 | 292 | with WheelFile.from_wheelfile(wf, buf) as cwf: 293 | czf = cwf.infolist()[0] 294 | 295 | assert getattr(czf, attr) == getattr(zf, attr) 296 | 297 | @pytest.mark.parametrize("attr", PRESERVED_ZIPINFO_ATTRS) 298 | def test_zip_attributes_are_preserved_writestr_data(self, wf, buf, attr): 299 | zf = self.custom_zipinfo() 300 | wf.writestr_data("section", zf, b"data") 301 | 302 | with WheelFile.from_wheelfile(wf, buf) as cwf: 303 | czf = cwf.infolist()[0] 304 | 305 | assert getattr(czf, attr) == getattr(zf, attr) 306 | 307 | @pytest.mark.parametrize("attr", PRESERVED_ZIPINFO_ATTRS) 308 | def test_zip_attributes_are_preserved_writestr_distinfo(self, wf, buf, attr): 309 | zf = self.custom_zipinfo() 310 | wf.writestr_distinfo(zf, b"data") 311 | 312 | with WheelFile.from_wheelfile(wf, buf) as cwf: 313 | czf = cwf.infolist()[0] 314 | 315 | assert getattr(czf, attr) == getattr(zf, attr) 316 | 317 | def test_data_directory_is_renamed(self, wf, buf): 318 | wf.writestr_data("section_xyz", "file", b"data") 319 | 320 | with WheelFile.from_wheelfile( 321 | wf, buf, distname="new_distname", version="123" 322 | ) as cwf: 323 | assert cwf.namelist()[0] == "new_distname-123.data/section_xyz/file" 324 | 325 | def test_dist_info_directory_is_renamed(self, wf, buf): 326 | wf.writestr_distinfo("file", b"data") 327 | 328 | with WheelFile.from_wheelfile( 329 | wf, buf, distname="new_distname", version="123" 330 | ) as cwf: 331 | assert cwf.namelist()[0] == "new_distname-123.dist-info/file" 332 | 333 | 334 | class TestMetadataCloning: 335 | 336 | def test_wheeldata_build_tag_is_kept_by_default(self, buf): 337 | buf1 = io.BytesIO() 338 | buf2 = io.BytesIO() 339 | wf = WheelFile(buf1, "w", distname="_", version="0", build_tag=321) 340 | 341 | with WheelFile.from_wheelfile( 342 | wf, buf2, distname="new_distname", version="123" 343 | ) as cwf: 344 | assert cwf.wheeldata.build == 321 345 | 346 | # TODO: also implement tests for .tags 347 | # TODO: also implement tests for MetaData.distname and MetaData.version 348 | @pytest.mark.xfail(reason="Not implemented yet") 349 | def test_wheeldata_build_tag_is_kept_if_wf_changed(self, wf): 350 | buf1 = io.BytesIO() 351 | buf2 = io.BytesIO() 352 | with WheelFile(buf1, "w", distname="_", version="0", build_tag=321) as wf: 353 | wf.wheeldata.build = 12345 354 | 355 | with WheelFile.from_wheelfile( 356 | wf, buf2, distname="new_distname", version="123" 357 | ) as cwf: 358 | assert cwf.wheeldata.build == 12345 359 | 360 | def test_wheeldata_build_tag_is_not_kept_if_new_given(self, wf): 361 | buf1 = io.BytesIO() 362 | buf2 = io.BytesIO() 363 | wf = WheelFile(buf1, "w", distname="_", version="0", build_tag=321) 364 | wf.wheeldata.build = 12345 365 | 366 | with WheelFile.from_wheelfile( 367 | wf, buf2, distname="new_distname", version="123", build_tag=999 368 | ) as cwf: 369 | assert cwf.wheeldata.build == 999 370 | 371 | def test_generator_is_marked_as_wheeldfile(self, wf, buf): 372 | wf.wheeldata.generator = "something else" 373 | 374 | with WheelFile.from_wheelfile(wf, buf) as cwf: 375 | assert cwf.wheeldata.generator == "wheelfile " + __version__ 376 | 377 | def test_explicit_tags_are_put_into_wheeldata(self, wf, buf): 378 | wf.wheeldata.tags = ["something-completely-different"] 379 | with WheelFile.from_wheelfile( 380 | wf, 381 | buf, 382 | language_tag="expected", 383 | abi_tag="expected", 384 | platform_tag="expected", 385 | ) as cwf: 386 | assert cwf.wheeldata.tags == ["expected-expected-expected"] 387 | 388 | # Fully customized MetaData args 389 | full_metadata = MetaDataTests.full_usage 390 | 391 | def test_metadata_stays_the_same(self, wf, buf, full_metadata): 392 | # ...apart from distname and version 393 | 394 | wf.metadata = MetaData(**full_metadata) 395 | 396 | with WheelFile.from_wheelfile(wf, buf) as cwf: 397 | wf.metadata.name = cwf.distname 398 | wf.metadata.version = cwf.version 399 | assert cwf.metadata == wf.metadata 400 | 401 | # Get back the old values so that wf.validate doesn't complain 402 | wf.metadata.name = wf.distname 403 | wf.metadata.version = wf.version 404 | 405 | def test_metadata_gets_new_distname(self, wf, buf, full_metadata): 406 | wf.metadata = MetaData(**full_metadata) 407 | 408 | new_distname = "cloned_dist_123" 409 | with WheelFile.from_wheelfile(wf, buf, distname=new_distname) as cwf: 410 | assert cwf.metadata.name == new_distname 411 | 412 | def test_metadata_gets_new_version(self, wf, buf, full_metadata): 413 | wf.metadata = MetaData(**full_metadata) 414 | 415 | new_version = str(wf.version) + "+myversion" 416 | with WheelFile.from_wheelfile(wf, buf, version=new_version) as cwf: 417 | assert cwf.metadata.version == Version(new_version) 418 | 419 | def test_when_metadata_is_missing_uses_defaults(self, buf): 420 | with WheelFile(io.BytesIO(), "w", distname="_", version="0") as wf: 421 | wf.wheeldata = None 422 | 423 | with WheelFile.from_wheelfile(wf, buf) as cwf: 424 | assert str(cwf.metadata) == ( 425 | "Metadata-Version: 2.1\n" "Name: _\n" "Version: 0\n\n" 426 | ) 427 | 428 | def test_when_wheeldata_is_missing_uses_defaults(self, buf): 429 | with WheelFile(io.BytesIO(), "w", distname="_", version="0") as wf: 430 | wf.wheeldata = None 431 | 432 | with WheelFile.from_wheelfile(wf, buf) as cwf: 433 | assert str(cwf.wheeldata) == ( 434 | "Wheel-Version: 1.0\n" 435 | "Generator: wheelfile " + __version__ + "\n" 436 | "Root-Is-Purelib: true\n" 437 | "Tag: py3-none-any\n\n" 438 | ) 439 | 440 | def test_when_record_is_missing_recreates_it(self, wf, buf): 441 | wf.writestr("file", b"data") 442 | wf.record = None 443 | 444 | with WheelFile.from_wheelfile(wf, buf) as cwf: 445 | assert "file" in cwf.record 446 | 447 | 448 | class TestNoOverwriting: 449 | def test_raises_VE_when_same_buf_used(self, wf): 450 | buf = wf.zipfile.fp 451 | with pytest.raises(ValueError): 452 | WheelFile.from_wheelfile(wf, buf) 453 | 454 | def test_raises_VE_when_same_fielobj_used(self, tmp_file): 455 | with open(tmp_file, "bw+") as f: 456 | with WheelFile(f, mode="w") as wf: 457 | with pytest.raises(ValueError): 458 | WheelFile.from_wheelfile(wf, f) 459 | 460 | def test_raises_VE_when_same_path_used(self, wf, buf, tmp_file): 461 | with WheelFile(tmp_file, mode="w") as wf: 462 | with pytest.raises(ValueError): 463 | WheelFile.from_wheelfile(wf, tmp_file) 464 | 465 | def test_raises_VE_when_same_path_used_relatively(self, wf, tmp_path): 466 | (tmp_path / "relative/").mkdir() 467 | (tmp_path / "path/").mkdir() 468 | path = tmp_path / "relative/../path/../" 469 | assert path.is_dir() 470 | with WheelFile(path, mode="w", distname="_", version="0") as wf: 471 | with pytest.raises(ValueError): 472 | WheelFile.from_wheelfile(wf, tmp_path) 473 | 474 | @pytest.fixture 475 | def tmp_cwd(self, tmp_path): 476 | old_dir = os.getcwd() 477 | os.chdir(tmp_path) 478 | yield tmp_path 479 | os.chdir(old_dir) 480 | 481 | def test_raises_VE_when_same_path_used_via_curdir(self, tmp_cwd): 482 | with WheelFile(tmp_cwd, mode="w", distname="_", version="0") as wf: 483 | with pytest.raises(ValueError): 484 | WheelFile.from_wheelfile(wf) 485 | -------------------------------------------------------------------------------- /tests/test_wheelfile_readmode.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from wheelfile import WheelFile 6 | 7 | 8 | @pytest.fixture 9 | def empty_wheel(tmp_path) -> Path: 10 | with WheelFile( 11 | tmp_path, "w", distname="wheelfile_test_wheel", version="0.0.0" 12 | ) as wf: 13 | pass 14 | return wf 15 | 16 | 17 | class TestWheelFileReadMode: 18 | def test_read_mode_is_the_default_one(self, empty_wheel): 19 | wf = WheelFile(empty_wheel.filename) 20 | assert wf.mode == "r" 21 | 22 | def test_close_in_read_mode_does_not_try_to_write(self, empty_wheel): 23 | wf = WheelFile(empty_wheel.filename) 24 | try: 25 | wf.close() 26 | except ValueError: # ValueError: write() requires mode 'w', 'x', or 'a' 27 | pytest.fail("Attempt to write on close() in read mode") 28 | 29 | def test_reads_metadata(self, empty_wheel): 30 | wf = WheelFile(empty_wheel.filename) 31 | assert wf.metadata == empty_wheel.metadata 32 | 33 | def test_reads_wheeldata(self, empty_wheel): 34 | wf = WheelFile(empty_wheel.filename) 35 | assert wf.wheeldata == empty_wheel.wheeldata 36 | 37 | def test_reads_record(self, empty_wheel): 38 | wf = WheelFile(empty_wheel.filename) 39 | assert wf.record == empty_wheel.record 40 | -------------------------------------------------------------------------------- /tests/test_wheels_with_vendoring.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | def test_allows_writing_record_files_in_subdirectories(wf): 5 | try: 6 | wf.writestr("some/subdir/.dist-info/RECORD", "additional record file") 7 | except Exception as exc: 8 | pytest.fail( 9 | f"Write failed, indicating inability to write RECORD in subdirs: {exc!r}" 10 | ) 11 | -------------------------------------------------------------------------------- /wheelfile.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020-2021 Blazej Michalik 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | """API for handling ".whl" files. 21 | 22 | Use :class:`WheelFile` to create or read a wheel. 23 | 24 | Managing metadata is done via `metadata`, `wheeldata`, and `record` attributes. 25 | See :class:`MetaData`, :class:`WheelData`, and :class:`WheelRecord` for 26 | documentation of the objects returned by these attributes. 27 | 28 | Example 29 | ------- 30 | Here's how to create a simple package under a specific directory path:: 31 | 32 | with WheelFile('path/to/directory/', mode='w' 33 | distname="mywheel", version="1") as wf: 34 | wf.write('path/to/a/module.py', arcname="mywheel.py") 35 | """ 36 | 37 | import base64 38 | import csv 39 | import hashlib 40 | import io 41 | import os 42 | import warnings 43 | import zipfile 44 | from collections import namedtuple 45 | from email import message_from_string 46 | from email.message import EmailMessage 47 | from email.policy import EmailPolicy 48 | from inspect import signature 49 | from pathlib import Path 50 | from string import ascii_letters, digits 51 | from typing import IO, BinaryIO, Dict, List, Optional, Union 52 | 53 | from packaging.tags import parse_tag 54 | from packaging.utils import canonicalize_name 55 | from packaging.version import InvalidVersion, Version 56 | 57 | __version__ = "0.0.9" 58 | 59 | 60 | # TODO: ensure that writing into `file` arcname and then into `file/not/really` 61 | # fails. 62 | # TODO: the file-directory confusion should also be checked on the WheelRecord 63 | # level, and inside WheelFile.validate() 64 | 65 | # TODO: idea: Corrupted class: denotes that something is present, but could not 66 | # be parsed. Would take a type and contents to parse, compare falsely to 67 | # the objects of given type, and not compare with anything else. 68 | # Validate would raise errors with messages about parsing if it finds something 69 | # corrupted, and about missing file otherwise. 70 | 71 | # TODO: change AssertionErrors to custom exceptions? 72 | # TODO: idea - install wheel - w/ INSTALLER file 73 | # TODO: idea - wheel from an installed distribution? 74 | 75 | # TODO: fix inconsistent referencing style of symbols in docstrings 76 | 77 | # TODO: parameters for path-like values should accept bytes 78 | 79 | # TODO: idea - wheeldata -> wheelinfo, but it contradicts the idea below 80 | # TODO: idea - might be better to provide WheelInfo objects via a getinfo(), 81 | # which would inherit from ZipInfo but also cointain the hash from the RECORD. 82 | # It would simplify the whole implementation. 83 | 84 | # TODO: fix usage of UnnamedDistributionError and ValueError - it is ambiguous 85 | 86 | 87 | def _slots_from_params(func): 88 | """List out slot names based on the names of parameters of func 89 | 90 | Usage: __slots__ = _slots_from_signature(__init__) 91 | """ 92 | funcsig = signature(func) 93 | slots = list(funcsig.parameters) 94 | slots.remove("self") 95 | return slots 96 | 97 | 98 | def _clone_zipinfo(zinfo: zipfile.ZipInfo, **to_replace) -> zipfile.ZipInfo: 99 | """Clone a ZipInfo object and update its attributes using to_replace.""" 100 | 101 | PRESERVED_ZIPINFO_ATTRS = [ 102 | "date_time", 103 | "compress_type", 104 | "_compresslevel", 105 | "comment", 106 | "extra", 107 | "create_system", 108 | "create_version", 109 | "extract_version", 110 | "volume", 111 | "internal_attr", 112 | "external_attr", 113 | ] 114 | 115 | # `orig_filename` instead of `filename` is used to prevent any possibility 116 | # of confusing ZipInfo filename normalization. 117 | new_name = zinfo.orig_filename 118 | if "filename" in to_replace: 119 | new_name = to_replace["filename"] 120 | del to_replace["filename"] 121 | 122 | new_zinfo = zipfile.ZipInfo(filename=new_name) 123 | for attr in PRESERVED_ZIPINFO_ATTRS: 124 | replaced = to_replace.get(attr) 125 | 126 | if replaced is not None: 127 | setattr(new_zinfo, attr, replaced) 128 | else: 129 | setattr(new_zinfo, attr, getattr(zinfo, attr)) 130 | 131 | return new_zinfo 132 | 133 | 134 | # TODO: accept packaging.requirements.Requirement in requires_dist, fix this in 135 | # example, ensure such objects are converted on __str__ 136 | # TODO: reimplement using dataclasses 137 | # TODO: add version to the class name, reword the "Note" 138 | # name regex for validation: ^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$ 139 | # TODO: helper-function or consts for description_content_type 140 | # TODO: parse version using packaging.version.parse? 141 | # TODO: values validation 142 | # TODO: validate provides_extras ↔ requires_dists? 143 | # TODO: validate values charset-wise 144 | # TODO: ensure name is the same as wheelfile namepath 145 | # TODO: PEP-643 - v2.2 146 | # TODO: don't raise invalid version, assign a degenerated version object instead 147 | class MetaData: 148 | """Implements Wheel Metadata format v2.1. 149 | 150 | Descriptions of parameters based on 151 | https://packaging.python.org/specifications/core-metadata/. All parameters 152 | are keyword only. Attributes of objects of this class follow parameter 153 | names. 154 | 155 | All parameters except "name" and "version" are optional. 156 | 157 | Note 158 | ---- 159 | Metadata-Version, the metadata format version specifier, is unchangable. 160 | Version "2.1" is used. 161 | 162 | Parameters 163 | ---------- 164 | name 165 | Primary identifier for the distribution that uses this metadata. Must 166 | start and end with a letter or number, and consists only of ASCII 167 | alphanumerics, hyphen, underscore, and period. 168 | 169 | version 170 | A string that contains PEP-440 compatible version identifier. 171 | 172 | Can be specified using packaging.version.Version object, or a string, 173 | where the latter is always converted to the former. 174 | 175 | summary 176 | A one-line sentence describing this distribution. 177 | 178 | description 179 | Longer text that describes this distribution in detail. Can be written 180 | using plaintext, reStructuredText, or Markdown (see 181 | "description_content_type" parameter below). 182 | 183 | The string given for this field should not include RFC 822 indentation 184 | followed by a "|" symbol. Newline characters are permitted 185 | 186 | description_content_type 187 | Defines content format of the text put in the "description" argument. 188 | The field value should follow the following structure: 189 | 190 | ; charset=[; = ...] 191 | 192 | Valid type/subtype strings are: 193 | - text/plain 194 | - text/x-rst 195 | - text/markdown 196 | 197 | For charset parameter, the only legal value is UTF-8. 198 | 199 | For text/markdown, parameter "variant=" specifies variant of 200 | the markdown used. Currently recognized variants include "GFM" and 201 | "CommonMark". 202 | 203 | Examples: 204 | 205 | Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM 206 | 207 | Description-Content-Type: text/markdown 208 | 209 | keywords 210 | List of search keywords for this distribution. Optionally a single 211 | string literal with keywords separated by commas. 212 | 213 | Note: despite the name being a plural noun, the specification defines 214 | this field as a single-use field. In this implementation however, the 215 | value of the attribute after instance initialization is a list of 216 | strings, and conversions to and from string follow the spec - they 217 | require a comma-separated list. 218 | 219 | classifiers 220 | List PEP-301 classification values for this distribution, optionally 221 | followed by a semicolon and an environmental marker. 222 | 223 | Example of a classifier: 224 | 225 | Operating System :: Microsoft :: Windows :: Windows 10 226 | 227 | author 228 | Name and, optionally, contact information of the original author of the 229 | distribution. 230 | 231 | author_email 232 | Email address of the person specified in the "author" parameter. Format 233 | of this field must follow the format of RFC-822 "From:" header field. 234 | 235 | maintainer 236 | Name and, optionally, contact information of person currently 237 | maintaining the project to which this distribution belongs to. 238 | 239 | Omit this parameter if the author and current maintainer is the same 240 | person. 241 | 242 | maintainer_email 243 | Email address of the person specified in the "maintainer" parameter. 244 | Format of this field must follow the format of RFC-822 "From:" header 245 | field. 246 | 247 | Omit this parameter if the author and current maintainer is the same 248 | person. 249 | 250 | license 251 | Text of the license that covers this distribution. If license 252 | classifier is used, this parameter may be omitted or used to specify the 253 | particular version of the intended legal text. 254 | 255 | home_page 256 | URL of the home page for this distribution (project). 257 | 258 | download_url 259 | URL from which this distribution (in this version) can be downloaded. 260 | 261 | project_urls 262 | List of URLs with labels for them, in the following format: 263 | 264 |
6 |

Wheelfile 🔪🧀

7 | 8 | This library aims to make it dead simple to create a format-compliant [.whl 9 | file (wheel)](https://pythonwheels.com/). It provides an API comparable to 10 | [zipfile](https://docs.python.org/3/library/zipfile.html). Use this if you wish 11 | to inspect or create wheels in your code. 12 | 13 | For a quick look, see the example on the right, which packages the wheelfile 14 | module itself into a wheel 🤸. 15 | 16 | #### What's the difference between this and [wheel](https://pypi.org/project/wheel/)? 17 | 18 | "Wheel" tries to provide a reference implementation for the standard. It is used 19 | by setuptools and has its own CLI, but no stable API. The goal of Wheelfile is 20 | to provide a simple API. 21 | 22 | Wheelfile does not depend on Wheel. 23 | 24 | ## Acknowledgements 25 | 26 | Thanks to [Paul Moore](https://github.com/pfmoore) for providing 27 | [his gist](https://gist.github.com/pfmoore/20f3654ca33f8b14f0fcb6dfa1a6b469) 28 | of basic metadata parsing logic, which helped to avoid many foolish mistakes 29 | in the initial implementation. 30 | 31 |
33 | 34 |
35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 |
44 |
45 | 46 | ``` 47 | pip install wheelfile 48 | ``` 49 | 50 | ```py 51 | from wheelfile import WheelFile, __version__ 52 | 53 | spec = { 54 | 'distname': 'wheelfile', 55 | 'version': __version__ 56 | } 57 | 58 | requirements = [ 59 | 'packaging >= 20.8', 60 | ] 61 | 62 | with WheelFile(mode='w', **spec) as wf: 63 | wf.metadata.requires_dists = requirements 64 | wf.write('./wheelfile.py') 65 | 66 | # 🧀 67 | ``` 68 |
69 | More examples: 70 | buildscript | 71 | PEP-517 builder 72 | 73 |
74 | 75 |