├── .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 |
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 |
32 |
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 |
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 | ,
265 |
266 | The label must be at most 32 characters.
267 |
268 | Example of an item of this list:
269 |
270 | Repository, https://github.com/MrMino/wheelfile
271 |
272 | platforms
273 | List of strings that signify supported operating systems. Use only if
274 | an OS cannot be listed by using a classifier.
275 |
276 | supported_platforms
277 | In binary distributions list of strings, each defining an operating
278 | system and a CPU for which the distribution was compiled.
279 |
280 | Semantics of this field aren't formalized by metadata specifications.
281 |
282 | requires_python
283 | PEP-440 version identifier, that specifies the set Python language
284 | versions that this distribution is compatible with.
285 |
286 | Some package management tools (most notably pip) use the value of this
287 | field to filter out installation candidates.
288 |
289 | Example:
290 |
291 | ~=3.5,!=3.5.1,!=3.5.0
292 |
293 | requires_dists
294 | List of PEP-508 dependency specifiers (think line-split contents of
295 | requirements.txt).
296 |
297 | requires_externals
298 | List of system dependencies that this distribution requires.
299 |
300 | Each item is a string with a name of the dependency optionally followed
301 | by a version (in the same way items in "requires_dists") are specified.
302 |
303 | Each item may end with a semicolon followed by a PEP-496 environment
304 | markers.
305 |
306 | provides_extras
307 | List of names of optional features provided by a distribution. Used to
308 | specify which dependencies should be installed depending on which of
309 | these optional features are requested.
310 |
311 | For example, if you specified "network" and "ssh" as optional features,
312 | the following requirement specifier can be used in "requires_externals"
313 | list to indicate, that the "paramiko" dependency should only be
314 | installed when "ssh" feature is requested:
315 |
316 | paramiko; extra == "ssh"
317 |
318 | or
319 |
320 | paramiko[ssh]
321 |
322 | If a dependency is required by multiple features, the features can be
323 | specified in a square brackets, separated by commas:
324 |
325 | ipython[repl, jupyter_kernel]
326 |
327 | Specifying an optional feature without using it in "requires_externals"
328 | is considered invalid.
329 |
330 | Feature names "tests" and "doc" are reserved in their semantics. They
331 | can be used for dependencies of automated testing or documentation
332 | generation.
333 |
334 | provides_dists
335 | List of names of other distributions contained within this one. Each
336 | entry must follow the same format that entries in "requires_dists" list
337 | do.
338 |
339 | Different distributions may use a name that does not correspond to any
340 | particular project, to indicate a capability to provide a certain
341 | feature, e.g. "relational_db" may be used to say that a project
342 | provides relational database capabilities
343 |
344 | obsoletes_dists
345 | List of names of distributions obsoleted by installing this one,
346 | indicating that they should not coexist in a single environment with
347 | this one. Each entry must follow the same format that entries in
348 | "requires_dists" list do.
349 | """
350 |
351 | def __init__(
352 | self,
353 | *,
354 | name: str,
355 | version: Union[str, Version],
356 | summary: Optional[str] = None,
357 | description: Optional[str] = None,
358 | description_content_type: Optional[str] = None,
359 | keywords: Union[List[str], str, None] = None,
360 | classifiers: Optional[List[str]] = None,
361 | author: Optional[str] = None,
362 | author_email: Optional[str] = None,
363 | maintainer: Optional[str] = None,
364 | maintainer_email: Optional[str] = None,
365 | license: Optional[str] = None,
366 | home_page: Optional[str] = None,
367 | download_url: Optional[str] = None,
368 | project_urls: Optional[List[str]] = None,
369 | platforms: Optional[List[str]] = None,
370 | supported_platforms: Optional[List[str]] = None,
371 | requires_python: Optional[str] = None,
372 | requires_dists: Optional[List[str]] = None,
373 | requires_externals: Optional[List[str]] = None,
374 | provides_extras: Optional[List[str]] = None,
375 | provides_dists: Optional[List[str]] = None,
376 | obsoletes_dists: Optional[List[str]] = None,
377 | ):
378 | # self.metadata_version = '2.1' by property
379 | self.name = name
380 | self.version = Version(version) if isinstance(version, str) else version
381 |
382 | self.summary = summary
383 | self.description = description
384 | self.description_content_type = description_content_type
385 | if keywords is None:
386 | keywords = []
387 | self.keywords = keywords if isinstance(keywords, list) else keywords.split(",")
388 | self.classifiers = classifiers or []
389 |
390 | self.author = author
391 | self.author_email = author_email
392 | self.maintainer = maintainer
393 | self.maintainer_email = maintainer_email
394 |
395 | self.license = license
396 |
397 | self.home_page = home_page
398 | self.download_url = download_url
399 | self.project_urls = project_urls or []
400 |
401 | self.platforms = platforms or []
402 | self.supported_platforms = supported_platforms or []
403 |
404 | self.requires_python = requires_python
405 | self.requires_dists = requires_dists or []
406 | self.requires_externals = requires_externals or []
407 | self.provides_extras = provides_extras or []
408 | self.provides_dists = provides_dists or []
409 | self.obsoletes_dists = obsoletes_dists or []
410 |
411 | __slots__ = _slots_from_params(__init__)
412 |
413 | @property
414 | def metadata_version(self):
415 | return self._metadata_version
416 |
417 | _metadata_version = "2.1"
418 |
419 | @classmethod
420 | def field_is_multiple_use(cls, field_name: str) -> bool:
421 | field_name = field_name.lower().replace("-", "_").rstrip("s")
422 | if field_name in cls.__slots__ or field_name == "keyword":
423 | return False
424 | if field_name + "s" in cls.__slots__:
425 | return True
426 | else:
427 | raise ValueError(f"Unknown field: {repr(field_name)}.")
428 |
429 | @classmethod
430 | def _field_name(cls, attribute_name: str) -> str:
431 | if cls.field_is_multiple_use(attribute_name):
432 | attribute_name = attribute_name[:-1]
433 | field_name = attribute_name.title()
434 | field_name = field_name.replace("_", "-")
435 | field_name = field_name.replace("Url", "URL")
436 | field_name = field_name.replace("-Page", "-page")
437 | field_name = field_name.replace("-Email", "-email")
438 | return field_name
439 |
440 | @classmethod
441 | def _attr_name(cls, field_name: str) -> str:
442 | if cls.field_is_multiple_use(field_name):
443 | field_name += "s"
444 | return field_name.lower().replace("-", "_")
445 |
446 | def __str__(self) -> str:
447 | m = EmailMessage(EmailPolicy(max_line_length=None))
448 | m.add_header("Metadata-Version", self.metadata_version)
449 | for attr_name in self.__slots__:
450 | content = getattr(self, attr_name)
451 | if not content:
452 | continue
453 |
454 | field_name = self._field_name(attr_name)
455 |
456 | if field_name == "Keywords":
457 | content = ",".join(content)
458 | elif field_name == "Version":
459 | content = str(content)
460 |
461 | if self.field_is_multiple_use(field_name):
462 | assert not isinstance(
463 | content, str
464 | ), f"Single string in multiple use attribute: {attr_name}"
465 |
466 | for value in content:
467 | m.add_header(field_name, value)
468 | elif field_name == "Description":
469 | m.set_payload(content)
470 | else:
471 | assert isinstance(
472 | content, str
473 | ), f"Expected string, got {type(content)} instead: {attr_name}"
474 | m.add_header(field_name, content)
475 | return str(m)
476 |
477 | def __eq__(self, other):
478 | if isinstance(other, MetaData):
479 | # Having None as a description is the same as having an empty string
480 | # in it. The former is put there by having an Optional[str]
481 | # argument, the latter is there due to semantics of email-style
482 | # parsing.
483 | # Ensure these two values compare equally in the description.
484 | mine = "" if self.description is None else self.description
485 | theirs = "" if other.description is None else other.description
486 | descriptions_equal = mine == theirs
487 |
488 | return (
489 | all(
490 | getattr(self, field) == getattr(other, field)
491 | for field in self.__slots__
492 | if field != "description"
493 | )
494 | and descriptions_equal
495 | )
496 | else:
497 | return NotImplemented
498 |
499 | @classmethod
500 | def from_str(cls, s: str) -> "MetaData":
501 | m = message_from_string(s)
502 |
503 | # TODO: validate this when the rest of the versions are implemented
504 | # assert m['Metadata-Version'] == cls._metadata_version
505 |
506 | del m["Metadata-Version"]
507 |
508 | args = {}
509 | for field_name in m.keys():
510 | attr = cls._attr_name(field_name)
511 | if not attr.endswith("s"):
512 | args[attr] = m.get(field_name)
513 | else:
514 | if field_name == "Keywords":
515 | args[attr] = m.get(field_name).split(",")
516 | else:
517 | args[attr] = m.get_all(field_name)
518 |
519 | args["description"] = m.get_payload()
520 |
521 | return cls(**args)
522 |
523 |
524 | # TODO: reimplement using dataclasses?
525 | # TODO: add version to the class name, reword the "Note"
526 | # TODO: values validation
527 | class WheelData:
528 | """Implements .dist-info/WHEEL file format.
529 |
530 | Descriptions of parameters based on PEP-427. All parameters are keyword
531 | only. Attributes of objects of this class follow parameter names.
532 |
533 | Note
534 | ----
535 | Wheel-Version, the wheel format version specifier, is unchangeable. Version
536 | "1.0" is used.
537 |
538 | Parameters
539 | ----------
540 | generator
541 | Name and (optionally) version of the generator that generated the wheel
542 | file. By default, "wheelfile {__version__}" is used.
543 |
544 | root_is_purelib
545 | Defines whether the root of the wheel file should be first unpacked into
546 | purelib directory (see distutils.command.install.INSTALL_SCHEMES).
547 |
548 | tags
549 | See PEP-425 - "Compatibility Tags for Built Distributions". Either a
550 | single string denoting one tag or a list of tags. Tags may contain
551 | compressed tag sets, in which case they will be expanded.
552 |
553 | By default, "py3-none-any" is used.
554 |
555 | build
556 | Optional build number. Used as a tie breaker when two wheels have the
557 | same version.
558 | """
559 |
560 | def __init__(
561 | self,
562 | *,
563 | generator: str = "wheelfile " + __version__,
564 | root_is_purelib: bool = True,
565 | tags: Union[List[str], str] = "py3-none-any",
566 | build: Optional[int] = None,
567 | ):
568 | # self.wheel_version = '1.0' by property
569 | self.generator = generator
570 | self.root_is_purelib = root_is_purelib
571 | self.tags = self._extend_tags(tags if isinstance(tags, list) else [tags])
572 | self.build = build
573 |
574 | __slots__ = _slots_from_params(__init__)
575 |
576 | @property
577 | def wheel_version(self) -> str:
578 | return "1.0"
579 |
580 | def _extend_tags(self, tags: List[str]) -> List[str]:
581 | extended_tags = []
582 | for tag in tags:
583 | extended_tags.extend([str(t) for t in parse_tag(tag)])
584 | return extended_tags
585 |
586 | def __str__(self) -> str:
587 | # TODO Custom exception? Exception message?
588 | assert isinstance(
589 | self.generator, str
590 | ), f"'generator' must be a string, got {type(self.generator)} instead"
591 | assert isinstance(self.root_is_purelib, bool), (
592 | f"'root_is_purelib' must be a boolean, got"
593 | f"{type(self.root_is_purelib)} instead"
594 | )
595 | assert isinstance(
596 | self.tags, list
597 | ), f"Expected a list in 'tags', got {type(self.tags)} instead"
598 | assert self.tags, "'tags' cannot be empty"
599 | assert (
600 | isinstance(self.build, int) or self.build is None
601 | ), f"'build' must be an int, got {type(self.build)} instead"
602 |
603 | m = EmailMessage()
604 | m.add_header("Wheel-Version", self.wheel_version)
605 | m.add_header("Generator", self.generator)
606 | m.add_header("Root-Is-Purelib", "true" if self.root_is_purelib else "false")
607 | for tag in self.tags:
608 | m.add_header("Tag", tag)
609 | if self.build is not None:
610 | m.add_header("Build", str(self.build))
611 |
612 | return str(m)
613 |
614 | @classmethod
615 | def from_str(cls, s: str) -> "WheelData":
616 | m = message_from_string(s)
617 | assert m["Wheel-Version"] == "1.0"
618 | args = {
619 | "generator": m.get("Generator"),
620 | "root_is_purelib": bool(m.get("Root-Is-Purelib")),
621 | "tags": m.get_all("Tag"),
622 | }
623 |
624 | if "build" in m:
625 | args["build"] = int(m.get("build"))
626 |
627 | return cls(**args)
628 |
629 | def __eq__(self, other):
630 | if isinstance(other, WheelData):
631 | return all(getattr(self, f) == getattr(other, f) for f in self.__slots__)
632 | else:
633 | return NotImplemented
634 |
635 |
636 | # TODO: add_entry method, that raises if entry for a path already exists
637 | # TODO: leave out hashes of *.pyc files?
638 | class WheelRecord:
639 | """Contains logic for creation and modification of RECORD files.
640 |
641 | Keeps track of files in the wheel and their hashes.
642 |
643 | For the full spec, see PEP-376 "RECORD" section, PEP-627,
644 | "The .dist-info directory" section of PEP-427, and
645 | https://packaging.python.org/specifications/recording-installed-packages/.
646 | """
647 |
648 | HASH_BUF_SIZE = 65536
649 |
650 | _RecordEntry = namedtuple("_RecordEntry", "path hash size")
651 |
652 | def __init__(self, hash_algo: str = "sha256"):
653 | self._records: Dict[str, WheelRecord._RecordEntry] = {}
654 | self._hash_algo = ""
655 | self.hash_algo = hash_algo
656 |
657 | @property
658 | def hash_algo(self) -> str:
659 | """Hash algorithm to use to generate RECORD file entries"""
660 | return self._hash_algo
661 |
662 | @hash_algo.setter
663 | def hash_algo(self, value: str):
664 | # per PEP-376
665 | if value not in hashlib.algorithms_guaranteed:
666 | raise UnsupportedHashTypeError(f"{repr(value)} is not a valid record hash.")
667 | # per PEP 427
668 | if value in ("md5", "sha1"):
669 | raise UnsupportedHashTypeError(f"{repr(value)} is a forbidden hash type.")
670 |
671 | # PEP-427 says the RECORD hash must be sha256 or better. Does that mean
672 | # hashes such as sha224 are forbidden as well though not specifically
673 | # called out?
674 |
675 | self._hash_algo = value
676 |
677 | def hash_of(self, arcpath) -> str:
678 | """Return the hash of a file in the archive this RECORD describes
679 |
680 |
681 | Parameters
682 | ----------
683 | arcpath
684 | Location of the file inside the archive.
685 |
686 | Returns
687 | -------
688 | str
689 | String in the form =, where algorithm is the
690 | name of the hashing agorithm used to generate the hash (see
691 | hash_algo), and base64_str is a string containing a base64 encoded
692 | version of the hash with any trailing '=' removed.
693 | """
694 | return self._records[arcpath].hash
695 |
696 | def __str__(self) -> str:
697 | buf = io.StringIO()
698 | records = csv.DictWriter(buf, fieldnames=self._RecordEntry._fields)
699 | for entry in self._records.values():
700 | records.writerow(entry._asdict())
701 | return buf.getvalue()
702 |
703 | @classmethod
704 | def from_str(cls, s) -> "WheelRecord":
705 | record = WheelRecord()
706 | buf = io.StringIO(s)
707 | reader = csv.DictReader(buf, cls._RecordEntry._fields)
708 | for row in reader:
709 | entry = cls._RecordEntry(**row)
710 |
711 | if entry.path.endswith("/"):
712 | raise RecordContainsDirectoryError(
713 | "RECORD of this wheel contains an entry with for a "
714 | "directory: {repr(entry.path)}."
715 | )
716 |
717 | record._records[entry.path] = entry
718 | return record
719 |
720 | def update(self, arcpath: str, buf: IO[bytes]):
721 | """Add a record entry for a file in the archive.
722 |
723 | Parameters
724 | ----------
725 | arcpath
726 | Path in the archive of the file that the entry describes.
727 |
728 | buf
729 | Buffer from which the data will be read in HASH_BUF_SIZE chunks.
730 | Must be fresh, i.e. seek(0)-ed.
731 |
732 | Raises
733 | ------
734 | RecordContainsDirectoryError
735 | If ``arcpath`` is a path to a directory.
736 | """
737 | assert buf.tell() == 0, f"Stale buffer given - current position: {buf.tell()}."
738 | # if .dist-info/RECORD is not in a subdirectory, it is not allowed
739 | assert "/" in arcpath.replace(".dist-info/RECORD", "") or not arcpath.endswith(
740 | ".dist-info/RECORD"
741 | ), (
742 | f"Attempt to add an entry for a RECORD file to the RECORD: "
743 | f"{repr(arcpath)}."
744 | )
745 |
746 | if arcpath.endswith("/"):
747 | raise RecordContainsDirectoryError(
748 | f"Attempt to add an entry for a directory: {repr(arcpath)}"
749 | )
750 |
751 | self._records[arcpath] = self._entry(arcpath, buf)
752 |
753 | def remove(self, arcpath: str):
754 | del self._records[arcpath]
755 |
756 | def _entry(self, arcpath: str, buf: IO[bytes]) -> _RecordEntry:
757 | size = 0
758 | hasher = getattr(hashlib, self.hash_algo)()
759 | while True:
760 | data = buf.read(self.HASH_BUF_SIZE)
761 | size += len(data)
762 | if not data:
763 | break
764 | hasher.update(data)
765 | hash_entry = f"{hasher.name}={self._hash_encoder(hasher.digest())}"
766 | return self._RecordEntry(arcpath, hash_entry, size)
767 |
768 | @staticmethod
769 | def _hash_encoder(data: bytes) -> str:
770 | """
771 | Encode a file hash per PEP 376 spec
772 |
773 | From the spec:
774 | The hash is either the empty string or the hash algorithm as named in
775 | hashlib.algorithms_guaranteed, followed by the equals character =,
776 | followed by the urlsafe-base64-nopad encoding of the digest
777 | (base64.urlsafe_b64encode(digest) with trailing = removed).
778 | """
779 | return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
780 |
781 | def __eq__(self, other):
782 | if isinstance(other, WheelRecord):
783 | return str(self) == str(other)
784 | else:
785 | return NotImplemented
786 |
787 | def __contains__(self, path):
788 | return path in self._records
789 |
790 |
791 | class UnsupportedHashTypeError(ValueError):
792 | """The given hash name is not allowed by the spec."""
793 |
794 |
795 | class RecordContainsDirectoryError(ValueError):
796 | """Record contains an entry path that ends with a slash (a directory)."""
797 |
798 |
799 | class BadWheelFileError(ValueError):
800 | """The given file cannot be interpreted as a wheel nor fixed."""
801 |
802 |
803 | class UnnamedDistributionError(BadWheelFileError):
804 | """Distribution name cannot be deduced from arguments."""
805 |
806 |
807 | class ProhibitedWriteError(ValueError):
808 | """Writing into given arcname would result in a corrupted package."""
809 |
810 |
811 | def resolved(path: Union[str, Path]) -> str:
812 | """Get the name of the file or directory the path points to.
813 |
814 | This is a convenience function over the functionality provided by
815 | `resolve` argument of `WheelFile.write` and similar methods. Since
816 | `resolve=True` is ignored when `arcname` is given, it is impossible to add
817 | arbitrary prefix to `arcname` without resolving the path first - and this
818 | is what this function provides.
819 |
820 | Using this, you can have both custom `arcname` prefix and the "resolve"
821 | functionality, like so::
822 |
823 | wf = WheelFile(...)
824 | wf.write(some_path, arcname="arc/dir/" + resolved(some_path))
825 |
826 | Parameters
827 | ----------
828 | path
829 | Path to resolve.
830 |
831 |
832 | Returns
833 | -------
834 | str
835 | The name of the file or directory the `path` points to.
836 | """
837 | return os.path.basename(os.path.abspath(path))
838 |
839 |
840 | # TODO: read
841 | # TODO: read_distinfo
842 | # TODO: read_data
843 | # TODO: prevent arbitrary writes to METADATA, WHEEL, and RECORD - or make sure
844 | # the writes are reflected internally
845 | # TODO: prevent adding .dist-info directories if there's one already there
846 | # TODO: ensure distname and varsion have no weird characters (!slashes!)
847 | # TODO: debug propery, as with ZipFile.debug
848 | # TODO: comment property
849 | # TODO: append mode
850 | # TODO: writing inexistent metadata in lazy mode
851 | # TODO: better repr
852 | # TODO: comparison operators for comparing version + build number
853 | class WheelFile:
854 | """An archive that follows the wheel specification.
855 |
856 | Used to read, create, validate, or modify `.whl` files.
857 |
858 | Can be used as a context manager, in which case `close()` is called upon
859 | exiting the context.
860 |
861 | Attributes
862 | ----------
863 | filename : str
864 | Depending on what the class was initialized with, this attribute is either:
865 | - If initialized with an IO buffer: the filename that the wheel
866 | would have after saving it onto filesystem, considering other
867 | parameters given to `__init__`: `distname`, `version`,
868 | `build_tag`, `language_tag`, `abi_tag` and `platform_tag`.
869 | - If initialized with a path to a directory: the path to the wheel
870 | composed from the given path, and the filename that was generated
871 | using other parameters given to `__init__`.
872 | - A path to a file: that path, even if it is not compliant with the
873 | spec (in lazy mode).
874 |
875 | **Returned path is not resolved, and so might be relative and/or
876 | contain `../` and `./` segments**.
877 |
878 | distname : str
879 | Name of the distribution (project). Either given to __init__()
880 | explicitly or inferred from its file_or_path argument.
881 |
882 | version : packaging.version.Version
883 | Version of the distribution. Either given to __init__() explicitly or
884 | inferred from its file_or_path argument.
885 |
886 | build_tag : Optional[int]
887 | Distribution's build number. Either given to __init__() explicitly or
888 | inferred from its file_or_path argument, otherwise `None` in lazy mode.
889 |
890 | language_tag : str
891 | Interpretter implementation compatibility specifier. See PEP-425 for
892 | the full specification. Either given to __init__() explicitly or
893 | inferred from its file_or_path argument otherwise an empty string in
894 | lazy mode.
895 |
896 | abi_tag : str
897 | ABI compatibility specifier. See PEP-425 for the full specification.
898 | Either given to __init__() explicitly or inferred from its file_or_path
899 | argument, otherwise an empty string in lazy mode.
900 |
901 | platform_tag : str
902 | Platform compatibility specifier. See PEP-425 for the full
903 | specification. Either given to __init__() explicitly or inferred from
904 | its file_or_path argument, otherwise an empty string in lazy mode.
905 |
906 | record : Optional[WheelRecord]
907 | Current state of .dist-info/RECORD file.
908 |
909 | When reading wheels in lazy mode, if the file does not exist or is
910 | misformatted, this attribute becomes None.
911 |
912 | In non-lazy modes this file is always read & validated on
913 | initialization.
914 | In write and exclusive-write modes, written to the archive on close().
915 |
916 | metadata : Optional[MetaData]
917 | Current state of .dist-info/METADATA file.
918 |
919 | Values from `distname` and `version` are used to provide required
920 | arguments when the file is created from scratch by `__init__()`.
921 |
922 | When reading wheels in lazy mode, if the file does not exist or is
923 | misformatted, this attribute becomes None.
924 |
925 | In non-lazy modes this file is always read & validated on
926 | initialization.
927 | In write and exclusive-write modes, written to the archive on close().
928 |
929 | wheeldata : Optional[WheelData]
930 | Current state of .dist-info/WHEELDATA file.
931 |
932 | Values from `build_tag`, `language_tag`, `abi_tag`, `platform_tag`, or
933 | their substitutes inferred from the filename are used to initialize
934 | this object.
935 |
936 | When reading wheels in lazy mode, if the file does not exist or is
937 | misformatted, this attribute becomes None.
938 |
939 | In non-lazy modes this file is always read & validated on
940 | initialization.
941 | In write and exclusive-write modes, written to the archive on close().
942 |
943 | distinfo_dirname
944 | Name of the ``.dist-info`` directory inside the archive wheel,
945 | without the trailing slash.
946 |
947 | data_dirname
948 | Name of the ``.data`` directory inside the archive wheel, without
949 | the trailing slash.
950 |
951 | closed : bool
952 | True if the underlying `ZipFile` object is closed, false otherwise.
953 | """
954 |
955 | VALID_DISTNAME_CHARS = set(ascii_letters + digits + "._")
956 | METADATA_FILENAMES = {"WHEEL", "METADATA", "RECORD"}
957 |
958 | # TODO: implement lazy mode
959 | # TODO: in lazy mode, log reading/missing metadata errors
960 | # TODO: warn on 'w' modes if filename does not end with .whl
961 | def __init__(
962 | self,
963 | file_or_path: Union[str, Path, BinaryIO] = "./",
964 | mode: str = "r",
965 | *,
966 | distname: Optional[str] = None,
967 | version: Optional[Union[str, Version]] = None,
968 | build_tag: Optional[Union[int, str]] = None,
969 | language_tag: Optional[str] = None,
970 | abi_tag: Optional[str] = None,
971 | platform_tag: Optional[str] = None,
972 | compression: int = zipfile.ZIP_DEFLATED,
973 | allowZip64: bool = True,
974 | compresslevel: Optional[int] = None,
975 | strict_timestamps: bool = True,
976 | ) -> None:
977 | # FIXME: Validation does not fail if filename differs from generated
978 | # filename, yet it validates the extension
979 | """Open or create a wheel file.
980 |
981 | In write and exclusive-write modes, if `file_or_path` is not specified,
982 | it is assumed to be the current directory. If the specified path is a
983 | directory, the wheelfile will be created inside it, with filename
984 | generated using the values given via `distname`, `version`,
985 | `build_tag`, `language_tag`, `abi_tag`, and `platfrom_tag` arguments.
986 | Each of these parameters is stored in a read-only property of the same
987 | name.
988 | If `file_or_path` is a path to a file, the wheel will be created
989 | under the specified path.
990 |
991 | If lazy mode is not specified:
992 |
993 | - In read and append modes, the file is validated using validate().
994 | Contents of metadata files inside .dist-info directory are read
995 | and converted into their respective object representations (see
996 | "metadata", "wheeldata", and "record" attributes).
997 | - In write and exclusive-write modes, object representations for
998 | each metadata file are created from scratch. They will be written
999 | to each of their respective .dist-info/ files on close().
1000 |
1001 | To skip the validation, e.g. if you wish to fix a misformated wheel,
1002 | use lazy mode ('l' - see description of the "mode" parameter).
1003 |
1004 | In lazy mode, if the opened file does not contain WHEEL, METADATA, or
1005 | RECORD (which is optional as per PEP-627), the attributes corresponding
1006 | to the missing data structures will be set to None.
1007 |
1008 | If any of the metadata files cannot be read due to a wrong format, they
1009 | are considered missing.
1010 |
1011 | Filename tags are only inferred if the filename contains 5 or 6
1012 | segments inbetween `'-'` characters. Otherwise, if any tag argument is
1013 | omitted, its attribute is set to an empty string.
1014 |
1015 | If the archive root contains a directory with a name ending with
1016 | '.dist-info', it is considered to be *the* metadata directory for the
1017 | wheel, even if the given/inferred distname and version do not match its
1018 | name.
1019 |
1020 | If the archive already contains either one of the aforementioned files,
1021 | they are read, but are not checked for consistency. Use validate() to
1022 | check whether there are errors, and fix() to fix them.
1023 |
1024 | There are currently 2 classes of errors which completely prevent a well
1025 | formatted zip file from being read by this class:
1026 |
1027 | - Unknown/incorrect distribution name/version - when the naming
1028 | scheme is violated in a way that prevents inferring these values
1029 | and the user hasn't provided these values, or provided ones that
1030 | do not conform to the specifications. In such case, the scope of
1031 | functioning features of this class would be limited to that of a
1032 | standard ZipFile, and is therefore unsupported.
1033 | - When there are multiple .data or .dist-info directories. This
1034 | would mean that the class would have to guess which are the
1035 | genuine ones - and we refuse the temptation to do that (see "The
1036 | Zen of Python").
1037 |
1038 | In other words, this class is liberal in what it accepts, but very
1039 | conservative in what it does (A.K.A. the robustness principle).
1040 |
1041 | Note
1042 | ----
1043 | Despite all of this, THERE ARE NO GUARANTEES being made as to whether a
1044 | misformatted file can be read or fixed by this class, and even if it is
1045 | currently, whether it will still be the case in the future versions.
1046 |
1047 | Parameters
1048 | ----------
1049 | file_or_path
1050 | Path to the file to open/create or a file-like object to use.
1051 |
1052 | mode
1053 | See zipfile.ZipFile docs for the list of available modes.
1054 |
1055 | In the read and append modes, the file given has to contain proper
1056 | PKZIP-formatted data.
1057 |
1058 | Adding "l" to the mode string turns on the "lazy mode". This
1059 | changes the behavior on initialization (see above), the behavior of
1060 | close() (see its docstring for more info), makes the archive
1061 | modifying methods refrain from refreshing the record & writing it
1062 | to the archive.
1063 |
1064 | Lazy mode should only be used in cases where a misformatted wheels
1065 | have to be read or fixed.
1066 |
1067 | distname
1068 | Name of the distribution for this wheelfile.
1069 |
1070 | If omitted, the name will be inferred from the filename given in
1071 | the path. If a file-like object is given instead of a path, it will
1072 | be inferred from its "name" attribute.
1073 |
1074 | The class requires this information, as it's used to infer the name
1075 | of the directory in the archive in which metadata should reside.
1076 |
1077 | This argument should be understood as an override for the values
1078 | calculated from the object given in "file_or_path" argument. It
1079 | should only be necessary when a file is read from memory or has a
1080 | misformatted name.
1081 |
1082 | Should be composed of alphanumeric characters and underscores only.
1083 | Must not be an empty string.
1084 |
1085 | See the description of "distname" attribute for more information.
1086 |
1087 | version
1088 | Version of the distribution in this wheelfile. Follows the same
1089 | semantics as "distname".
1090 |
1091 | The given value must be compliant with PEP-440 version identifier
1092 | specification.
1093 |
1094 | See the description of "version" attribute for more information.
1095 |
1096 | build
1097 | Optional build number specifier for the distribution.
1098 |
1099 | See `WheelData` docstring for information about semantics of this
1100 | field.
1101 |
1102 | If lazy mode is not specified, this value must be an integer or a
1103 | string that converts to one. Otherwise no checks for this value are
1104 | performed.
1105 |
1106 | language_tag
1107 | Language implementation specification. Used to distinguish
1108 | between distributions targetted at different versions of
1109 | interpreters.
1110 |
1111 | The given value should be in the same form as the ones appearing
1112 | in wheels' filenames.
1113 |
1114 | Defaults to `'py3'`, but only if an unnamed or a directory target
1115 | was given.
1116 |
1117 | abi_tag
1118 | In distributions that utilize compiled binaries, specifies the
1119 | version of the ABI that the binaries in the wheel are compatible
1120 | with.
1121 |
1122 | The given value should be in the same form as the ones appearing
1123 | in wheels' filenames.
1124 |
1125 | Defaults to `'none'`, but only if an unnamed or a directory target
1126 | was given.
1127 |
1128 | platform_tag
1129 | Used to specify platforms that the distribution is compatible with.
1130 |
1131 | The given value should be in the same form as the ones appearing
1132 | in wheels' filenames.
1133 |
1134 | Defaults to `'any'`, but only if an unnamed or a directory target
1135 | was given.
1136 |
1137 | compression
1138 | Compression method to use. By default `zipfile.ZIP_DEFLATED` is
1139 | used.
1140 |
1141 | See `zipfile.ZipFile` documentation for the full description. This
1142 | argument differs from its `ZipFile` counterpart in that here it is
1143 | keyword-only, and the default value is different.
1144 |
1145 | allowZip64
1146 | Flag used to indicate whether ZIP64 extensions should be used.
1147 |
1148 | See `zipfile.ZipFile` documentation for the full description. This
1149 | argument differs from its `ZipFile` counterpart in that here it is
1150 | keyword-only.
1151 |
1152 | compresslevel
1153 | Compression level to use when writing to the archive.
1154 |
1155 | See `zipfile.ZipFile` documentation for the full description. This
1156 | argument differs from its `ZipFile` counterpart in that here it is
1157 | keyword-only
1158 |
1159 | strict_timestamps
1160 | When `True`, files with modification times older than 1980-01-01 or
1161 | newer than 2107-12-31 are allowed. Keyword only.
1162 |
1163 | See `zipfile.ZipFile` documentation for the full description. This
1164 | argument differs from its `ZipFile` counterpart in that here it is
1165 | keyword-only.
1166 |
1167 | Raises
1168 | ------
1169 | UnnamedDistributionError
1170 | Raised if the distname or version cannot be inferred from the
1171 | given arguments.
1172 |
1173 | E.g. when the path does not contain the version, or the
1174 | file-like object has no "name" attribute to get the filename from,
1175 | and the information wasn't provided via other arguments.
1176 |
1177 | BadWheelFileError
1178 | Raised if the archive contains multiple '.dist-info' or '.data'
1179 | directories.
1180 |
1181 | zipfile.BadZipFile
1182 | If given file is not a proper zip.
1183 | """
1184 | assert not isinstance(
1185 | file_or_path, io.TextIOBase
1186 | ), "Text buffer given where a binary one was expected."
1187 |
1188 | if "a" in mode:
1189 | # Requires rewrite feature
1190 | raise NotImplementedError("Append mode is not supported yet")
1191 |
1192 | if "l" in mode:
1193 | warning = RuntimeWarning(
1194 | "Lazy mode is not fully implemented yet. "
1195 | "Methods of this class may raise exceptions where "
1196 | "documentation states lazy mode will suppress them."
1197 | )
1198 | warnings.warn(warning)
1199 |
1200 | # TODO: this should be read-only prop
1201 | self.mode = mode
1202 |
1203 | # These might be None in case a corrupted wheel is read in lazy mode
1204 | self.wheeldata: Optional[WheelData] = None
1205 | self.metadata: Optional[MetaData] = None
1206 | self.record: Optional[WheelRecord] = None
1207 |
1208 | self._strict_timestamps = strict_timestamps
1209 |
1210 | if isinstance(file_or_path, str):
1211 | file_or_path = Path(file_or_path)
1212 |
1213 | # TODO if value error, set build_tag to degenerated version, that
1214 | # compares with Version in a way that makes Version the higher one.
1215 | build_tag = int(build_tag) if build_tag is not None else None
1216 |
1217 | if self._is_unnamed_or_directory(file_or_path):
1218 | self._require_distname_and_version(distname, version)
1219 |
1220 | filename = self._get_filename(file_or_path)
1221 | self._pick_a_distname(filename, given_distname=distname)
1222 | self._pick_a_version(filename, given_version=version)
1223 | self._pick_tags(filename, build_tag, language_tag, abi_tag, platform_tag)
1224 |
1225 | if self._is_unnamed_or_directory(file_or_path):
1226 | assert distname is not None and version is not None # For Mypy
1227 | self._generated_filename = self._generate_filename(
1228 | self._distname,
1229 | self._version,
1230 | self._build_tag,
1231 | self._language_tag,
1232 | self._abi_tag,
1233 | self._platform_tag,
1234 | )
1235 | else:
1236 | self._generated_filename = ""
1237 |
1238 | if isinstance(file_or_path, Path):
1239 | file_or_path /= self._generated_filename
1240 |
1241 | # FIXME: the file is opened before validating the arguments, so this
1242 | # litters empty and corrupted wheels if any arg is wrong.
1243 | self._zip = zipfile.ZipFile(
1244 | file_or_path,
1245 | mode.strip("l"),
1246 | compression=compression,
1247 | allowZip64=allowZip64,
1248 | compresslevel=compresslevel,
1249 | strict_timestamps=strict_timestamps,
1250 | )
1251 |
1252 | # Used by distinfo_dirname, data_dirname, and _distinfo_path
1253 | self._distinfo_prefix: Optional[str] = None
1254 |
1255 | if "w" in mode or "x" in mode:
1256 | self._initialize_distinfo()
1257 | else:
1258 | self._distinfo_prefix = self._find_distinfo_prefix()
1259 | self._read_distinfo()
1260 |
1261 | if "l" not in mode:
1262 | self.validate()
1263 |
1264 | class _Sentinel:
1265 | """Used in `from_wheelfile` to specify that a given paramter should be
1266 | copied from the source object.
1267 | """
1268 |
1269 | _unspecified = _Sentinel()
1270 |
1271 | @classmethod
1272 | def from_wheelfile(
1273 | cls,
1274 | wf: "WheelFile",
1275 | file_or_path: Union[str, Path, BinaryIO] = "./",
1276 | mode: str = "w",
1277 | *,
1278 | distname: Union[str, None, _Sentinel] = _unspecified,
1279 | version: Union[str, Version, None, _Sentinel] = _unspecified,
1280 | build_tag: Union[int, str, None, _Sentinel] = _unspecified,
1281 | language_tag: Union[str, None, _Sentinel] = _unspecified,
1282 | abi_tag: Union[str, None, _Sentinel] = _unspecified,
1283 | platform_tag: Union[str, None, _Sentinel] = _unspecified,
1284 | compression: Optional[int] = None,
1285 | allowZip64: bool = True,
1286 | compresslevel: Optional[int] = None,
1287 | strict_timestamps: bool = True,
1288 | ) -> "WheelFile":
1289 | """Recreate `wf` using different parameters.
1290 |
1291 | Creates a new `WheelFile` object using data from another, given as
1292 | `wf`, and given constructor parameters. The new object will contain
1293 | the same files (including those under `.data` and `.dist-info`
1294 | directories), with the same timestamps, compression methods,
1295 | compression levels, access modes, etc., *except*:
1296 |
1297 | - `.dist-info` directory is renamed, if `version` or `distname` is
1298 | changed.
1299 | - `.data` directory is renamed, if `version` or `distname` is
1300 | changed.
1301 | - `METADATA` will contain almost all the same information, except
1302 | for the fields that were changed via arguments of this method.
1303 | - `WHEEL` will be changed to contain the new `tags` and `build`
1304 | number. The `generator` field will be reset to the one given by
1305 | `WheelData` by default: `"wheelfile "`.
1306 | - `RECORD` is reconstructed in order to accomodate the differences
1307 | above.
1308 |
1309 | This can be used to rename a wheel, change its metadata, or add files
1310 | to it. It also fixes some common problems of wheel packages, e.g.
1311 | unnormalized dist-info directory names.
1312 |
1313 | If any of the metadata files is missing or corrupted in `wf`, i.e. the
1314 | properties `metadata`, `wheeldata`, and `record` of `wf` are set to
1315 | `None`, new ones with will be created for the new object, using default
1316 | values.
1317 |
1318 | All parameters of this method except `wf` are passed to the new
1319 | object's `__init__`.
1320 |
1321 | For `distname`, `version`, and `*_tag` arguments, if a parameter is not
1322 | given, the value from `wf` is used. If a default is needed instead, set
1323 | the argument to `None` explicitly.
1324 |
1325 | Even if `wf` was created using a path to a directory, the default value
1326 | used for `file_or_path` will be the current working directory.
1327 |
1328 | The new `WheelFile` object must be writable, so by default `"w"` mode
1329 | is used, instead of `"r"`. If copying `wf` would result in overwriting
1330 | a file or buffer from which `wf` was created, `ValueError` will be
1331 | raised.
1332 |
1333 | Parameters
1334 | ----------
1335 | wf
1336 | `WheelFile` object from which the new object should be recreated.
1337 |
1338 | mode
1339 | Mode in which the new `WheelFile` should be opened. Accepts the
1340 | same modes as `WheelFile.__init__`, except read mode (`"r"`) is not
1341 | allowed.
1342 |
1343 | distname, version, build_tag, language_tag, abi_tag, platform_tag
1344 | Optional argument passed to the new object's constructor. See
1345 | `WheelFile.__init__` for the full description. If not specified,
1346 | value from `wf` is used (it is avaialable via property of the same
1347 | name). If `None` is given explicitly, constructor's default is
1348 | used.
1349 |
1350 | compression, allowZip64, compresslevel, strict_timestamps
1351 | Optional argument passed to the new object's constructor, which in
1352 | turn passes them to `zipfile.ZipFile` - see `zipfile` docs for full
1353 | description on each.
1354 |
1355 | Value used to construct `wf` is *not* reused for these parameters.
1356 |
1357 | For `compression` and `compresslevel`, if the value is not passed,
1358 | the values from the original archive are preserved. Internally the
1359 | data is copied using `ZipFile.writestr` with `ZipInfo` attributes
1360 | of the files preserved. If the value is passed, these normally
1361 | preserved attributes are substituted.
1362 |
1363 | Raises
1364 | ------
1365 | ValueError
1366 | Raised when:
1367 | - Read mode is used (`"r"`) in `mode`.
1368 | - Creating the object would result in rewriting the underlying
1369 | buffer of `wf` (if `wf` uses an IO buffer).
1370 | - Creating the object would result in rewriting the file on
1371 | which `wf` operates.
1372 | """
1373 | if "r" in mode:
1374 | raise ValueError(
1375 | f'Passing "r" as mode is not allowed when recreating a '
1376 | f"wheel: {repr('mode')}."
1377 | )
1378 |
1379 | if distname is cls._unspecified:
1380 | distname = wf.distname
1381 | if version is cls._unspecified:
1382 | version = wf.version
1383 | if build_tag is cls._unspecified:
1384 | build_tag = wf.build_tag
1385 | if language_tag is cls._unspecified:
1386 | language_tag = wf.language_tag
1387 | if abi_tag is cls._unspecified:
1388 | abi_tag = wf.abi_tag
1389 | if platform_tag is cls._unspecified:
1390 | platform_tag = wf.platform_tag
1391 |
1392 | # Required for the path check below
1393 | if isinstance(version, str):
1394 | version = Version(version)
1395 | if isinstance(file_or_path, str):
1396 | file_or_path = Path(file_or_path)
1397 |
1398 | # For MyPy
1399 | assert isinstance(distname, str) or distname is None
1400 | assert isinstance(version, (str, Version)) or version is None
1401 | assert isinstance(build_tag, (int, str)) or build_tag is None
1402 | assert isinstance(language_tag, str) or language_tag is None
1403 | assert isinstance(abi_tag, str) or abi_tag is None
1404 | assert isinstance(platform_tag, str) or platform_tag is None
1405 |
1406 | if file_or_path == wf.zipfile.fp:
1407 | raise ValueError(
1408 | "Buffer object used to save the new wheel cannot be the "
1409 | "same as the one used by the other WheelFile object."
1410 | )
1411 |
1412 | f_o_p = file_or_path # For brevity
1413 | dir_path = (
1414 | f_o_p.resolve()
1415 | if isinstance(f_o_p, Path) and f_o_p.is_dir()
1416 | else (
1417 | f_o_p.parent.resolve()
1418 | if isinstance(f_o_p, Path)
1419 | else getattr(f_o_p, "name", None)
1420 | )
1421 | )
1422 |
1423 | # wf.zipfile.filename is not None only when it is writing to a file
1424 | both_are_files = wf.zipfile.filename is not None and dir_path is not None
1425 |
1426 | if both_are_files:
1427 | assert wf.zipfile.filename is not None # For MyPy
1428 | # Ensure we won't overwrite wf
1429 | wf_dir_path = Path(wf.zipfile.filename).parent.resolve()
1430 | if wf_dir_path == dir_path and (
1431 | wf.distname == distname
1432 | and wf.version == version
1433 | and wf.build_tag == build_tag
1434 | and wf.language_tag == language_tag
1435 | and wf.abi_tag == abi_tag
1436 | and wf.platform_tag == platform_tag
1437 | ):
1438 | raise ValueError(
1439 | "Operation would overwrite the old wheel - "
1440 | "both objects' paths point at the same file."
1441 | )
1442 |
1443 | if compression is None:
1444 | default_compression = zipfile.ZIP_DEFLATED
1445 | else:
1446 | default_compression = compression
1447 |
1448 | new_wf = WheelFile(
1449 | file_or_path,
1450 | mode,
1451 | distname=distname,
1452 | version=version,
1453 | build_tag=build_tag,
1454 | language_tag=language_tag,
1455 | abi_tag=abi_tag,
1456 | platform_tag=platform_tag,
1457 | compression=default_compression,
1458 | allowZip64=allowZip64,
1459 | compresslevel=compresslevel,
1460 | strict_timestamps=strict_timestamps,
1461 | )
1462 |
1463 | assert new_wf.wheeldata is not None # For MyPy
1464 |
1465 | # TODO: if Corrupted() is implemented, make sure to test
1466 | # wf.metadata = Corupted (& wheeldata, & record)
1467 |
1468 | if wf.metadata is not None:
1469 | # TODO: use copy.deepcopy instead
1470 | new_wf.metadata = MetaData.from_str(str(wf.metadata))
1471 | new_wf.metadata.name = new_wf.distname
1472 | new_wf.metadata.version = new_wf.version
1473 |
1474 | if wf.wheeldata is not None:
1475 | new_wf.wheeldata.root_is_purelib = wf.wheeldata.root_is_purelib
1476 |
1477 | # TODO: if wf.wheeldata.build/tags are changed, the changed values
1478 | # should superseed those from wf's name in cloning, i.e. new_wf
1479 | # should have build/tags from wf in its own name, but build/tags
1480 | # from wf.wheeldata in its wheeldata.
1481 | #
1482 | # new_wf.wheeldata.tags = wf.wheeldata.tags
1483 | # new_wf.wheeldata.build = wf.wheeldata.build
1484 | #
1485 | # But the given build tag should always superseed the above:
1486 | #
1487 | # <<(dedent)
1488 | # if build_tag is not cls._unspecified:
1489 | # new_wf.wheeldata.build = build_tag
1490 | # <<
1491 | #
1492 | # Similar modality should be put in place for distname and version
1493 | # of wf.metadata.
1494 |
1495 | to_copy = wf.infolist()
1496 | for zinfo in to_copy:
1497 |
1498 | data = wf.zipfile.read(zinfo)
1499 |
1500 | arcname = zinfo.filename
1501 | arcname_head, *arcname_tail_parts = arcname.split("/")
1502 | arcname_tail = "/".join(arcname_tail_parts)
1503 | if arcname_head == wf.distinfo_dirname:
1504 | new_arcname = new_wf.distinfo_dirname + "/" + arcname_tail
1505 | zinfo = _clone_zipinfo(zinfo, filename=new_arcname)
1506 | elif arcname_head == wf.data_dirname:
1507 | new_arcname = new_wf.data_dirname + "/" + arcname_tail
1508 | zinfo = _clone_zipinfo(zinfo, filename=new_arcname)
1509 |
1510 | new_wf.writestr(
1511 | zinfo,
1512 | data,
1513 | compress_type=compression,
1514 | compresslevel=compresslevel,
1515 | )
1516 |
1517 | return new_wf
1518 |
1519 | @staticmethod
1520 | def _is_unnamed_or_directory(target: Union[Path, BinaryIO]) -> bool:
1521 | return (getattr(target, "name", None) is None) or (
1522 | isinstance(target, Path) and target.is_dir()
1523 | )
1524 |
1525 | @staticmethod
1526 | def _require_distname_and_version(
1527 | distname: Optional[str], version: Optional[Union[str, Version]]
1528 | ):
1529 | if distname is None:
1530 | raise UnnamedDistributionError(
1531 | "No distname provided and an unnamed object given."
1532 | )
1533 | if version is None:
1534 | raise UnnamedDistributionError(
1535 | "No version provided and an unnamed object given."
1536 | )
1537 |
1538 | @staticmethod
1539 | def _generate_filename(
1540 | distname: str,
1541 | version: Union[str, Version],
1542 | build_tag: Optional[Union[str, int]],
1543 | language_tag: str,
1544 | abi_tag: str,
1545 | platform_tag: str,
1546 | ) -> str:
1547 | if build_tag is None:
1548 | segments = [distname, str(version), language_tag, abi_tag, platform_tag]
1549 | else:
1550 | segments = [
1551 | distname,
1552 | str(version),
1553 | str(build_tag),
1554 | language_tag,
1555 | abi_tag,
1556 | platform_tag,
1557 | ]
1558 |
1559 | filename = "-".join(segments) + ".whl"
1560 | return filename
1561 |
1562 | @classmethod
1563 | def _get_filename(cls, file_or_path: Union[BinaryIO, Path]) -> Optional[str]:
1564 | """Return a filename from file obj or a path.
1565 |
1566 | If given file, the asumption is that the filename is within the value
1567 | of its `name` attribute.
1568 | If given a `Path`, assumes it is a path to an actual file, not a
1569 | directory.
1570 | If given an unnamed object, this returns None.
1571 | """
1572 | if cls._is_unnamed_or_directory(file_or_path):
1573 | return None
1574 |
1575 | # TODO: test this
1576 | # If a file object given, ensure its a filename, not a path
1577 | if isinstance(file_or_path, Path):
1578 | return file_or_path.name
1579 | else:
1580 | # File objects contain full path in ther name attribute
1581 | filename = Path(file_or_path.name).name
1582 | return filename
1583 |
1584 | def _pick_a_distname(
1585 | self, filename: Optional[str], given_distname: Union[None, str]
1586 | ):
1587 | # filename == None means an unnamed object was given
1588 | assert filename is not None or given_distname is not None
1589 |
1590 | if given_distname is not None:
1591 | distname = given_distname
1592 | else:
1593 | assert filename is not None # For MyPy
1594 | distname = filename.split("-")[0]
1595 | if distname == "":
1596 | raise UnnamedDistributionError(
1597 | f"No distname provided and the inferred filename does not "
1598 | f"contain a proper distname substring: {repr(filename)}."
1599 | )
1600 | self._distname = distname
1601 |
1602 | def _pick_a_version(
1603 | self, filename: Optional[str], given_version: Union[None, str, Version]
1604 | ):
1605 | # filename == None means an unnamed object was given
1606 | assert filename is not None or given_version is not None
1607 |
1608 | if isinstance(given_version, Version):
1609 | # We've got a valid object here, nothing else to do
1610 | self._version = given_version
1611 | return
1612 | elif isinstance(given_version, str):
1613 | version = given_version
1614 | elif given_version is not None:
1615 | raise TypeError(
1616 | "'version' must be either packaging.version.Version or a string"
1617 | )
1618 | else:
1619 | assert filename is not None # For MyPy
1620 | name_segments = filename.split("-")
1621 |
1622 | if len(name_segments) < 2 or name_segments[1] == "":
1623 | raise UnnamedDistributionError(
1624 | f"No version provided and the inferred filename does not "
1625 | f"contain a version segment: {repr(filename)}."
1626 | )
1627 | version = name_segments[1]
1628 |
1629 | try:
1630 | self._version = Version(version)
1631 | except InvalidVersion as e:
1632 | # TODO: assign degenerated version instead
1633 | raise ValueError(
1634 | f"Filename contains invalid version: {repr(version)}."
1635 | ) from e
1636 |
1637 | def _pick_tags(
1638 | self,
1639 | filename: Optional[str],
1640 | given_build: Optional[int],
1641 | given_language: Optional[str],
1642 | given_abi: Optional[str],
1643 | given_platform: Optional[str],
1644 | ):
1645 | # filename == None means an unnamed object was given
1646 | if filename is None:
1647 | self._build_tag = given_build
1648 | self._language_tag = given_language or "py3"
1649 | self._abi_tag = given_abi or "none"
1650 | self._platform_tag = given_platform or "any"
1651 | return
1652 |
1653 | if filename.endswith(".whl"):
1654 | filename = filename[:-4]
1655 |
1656 | segments = filename.split("-")
1657 | if not (len(segments) == 6 or len(segments) == 5):
1658 | segments = [""] * 5
1659 |
1660 | # TODO: test this when lazy mode is ready
1661 | if len(segments) == 6 and given_build is None:
1662 | try:
1663 | self._build_tag = int(segments[2])
1664 | except ValueError:
1665 | # TODO: set to degenerated build number instead
1666 | self._build_tag = None
1667 | else:
1668 | self._build_tag = given_build
1669 |
1670 | self._language_tag = given_language or segments[-3]
1671 | self._abi_tag = given_abi or segments[-2]
1672 | self._platform_tag = given_platform or segments[-1]
1673 |
1674 | def _initialize_distinfo(self):
1675 | collapsed_tags = "-".join(
1676 | (self._language_tag, self._abi_tag, self._platform_tag)
1677 | )
1678 | self.wheeldata = WheelData(build=self.build_tag, tags=collapsed_tags)
1679 | self.metadata = MetaData(name=self.distname, version=self.version)
1680 | self.record = WheelRecord()
1681 |
1682 | # TODO: test edge cases related to bad contents
1683 | # TODO: should "bad content" exceptions be saved for validate()?
1684 | # TODO: the try...excepts should use something stricter than "Exception"
1685 | # TODO: save the exceptions in Corrupted objects after they are implemented
1686 | def _read_distinfo(self):
1687 | try:
1688 | metadata = self.zipfile.read(self._distinfo_path("METADATA"))
1689 | self.metadata = MetaData.from_str(metadata.decode("utf-8"))
1690 | except Exception:
1691 | self.metadata = None
1692 |
1693 | try:
1694 | wheeldata = self.zipfile.read(self._distinfo_path("WHEEL"))
1695 | self.wheeldata = WheelData.from_str(wheeldata.decode("utf-8"))
1696 | except Exception:
1697 | self.wheeldata = None
1698 |
1699 | try:
1700 | record = self.zipfile.read(self._distinfo_path("RECORD"))
1701 | self.record = WheelRecord.from_str(record.decode("utf-8"))
1702 | except Exception:
1703 | self.record = None
1704 |
1705 | # TODO: check what are the common bugs with wheels and implement checks here
1706 | # TODO: test behavior if no candidates found
1707 | def _find_distinfo_prefix(self):
1708 | # TODO: this could use a walrus
1709 | candidates = {path.split("/")[0] for path in self.zipfile.namelist()}
1710 | candidates = {name for name in candidates if name.endswith(".dist-info")}
1711 | # TODO: log them onto debug
1712 | if len(candidates) > 1:
1713 | raise BadWheelFileError(
1714 | "Multiple .dist-info directories found in the archive."
1715 | )
1716 | if len(candidates) == 0:
1717 | raise BadWheelFileError(
1718 | "Archive does not contain any .dist-info directory."
1719 | )
1720 |
1721 | return candidates.pop()[: -len("dist-info")]
1722 |
1723 | @property
1724 | def filename(self) -> str:
1725 | return self._zip.filename or self._generated_filename
1726 |
1727 | @property
1728 | def distname(self) -> str:
1729 | return self._distname
1730 |
1731 | @property
1732 | def version(self) -> Version:
1733 | return self._version
1734 |
1735 | @property
1736 | def build_tag(self) -> Optional[int]:
1737 | return self._build_tag
1738 |
1739 | @property
1740 | def language_tag(self) -> str:
1741 | return self._language_tag
1742 |
1743 | @property
1744 | def abi_tag(self) -> str:
1745 | return self._abi_tag
1746 |
1747 | @property
1748 | def platform_tag(self) -> str:
1749 | return self._platform_tag
1750 |
1751 | @property
1752 | def distinfo_dirname(self):
1753 | return self._distinfo_path("", kind="dist-info")[:-1]
1754 |
1755 | @property
1756 | def data_dirname(self):
1757 | return self._distinfo_path("", kind="data")[:-1]
1758 |
1759 | # TODO: the baseline for this should be "is the wheel installable"?
1760 | # TODO: validate naming conventions, metadata, etc.
1761 | # TODO: use testwheel()
1762 | # TODO: idea: raise when completely out-of-spec, return a compliancy score?
1763 | # TODO: fail if there are multiple .dist-info or .data directories
1764 | # TODO: actually, having two .data directories doesn't seem like a big
1765 | # deal, it could be just unpacked in the same place rest of the contents
1766 | # of the wheel are
1767 | # TODO: use lint()?
1768 | # TODO: ensure there are no synonym files for metadata (maybe others?)
1769 | # TODO: the bottom-line semantics of this method should be: if validate()
1770 | # goes through, the wheel is installable. Of course there are other
1771 | # requirements.
1772 | # TODO: custom exception
1773 | # TODO: test every check
1774 | # TODO: check filename segments are not empty
1775 | # TODO: !in lazy mode, return exception objects instead of raising them!
1776 | def validate(self):
1777 | if self.filename is not None and not self.filename.endswith(".whl"):
1778 | raise ValueError(f"Filename must end with '.whl': {repr(self.filename)}")
1779 |
1780 | if self.distname == "":
1781 | raise ValueError("Distname cannot be an empty string.")
1782 |
1783 | distname_valid = set(self.distname) <= self.VALID_DISTNAME_CHARS
1784 | if not distname_valid:
1785 | raise ValueError(
1786 | f"Invalid distname: {repr(self.distname)}. Distnames should "
1787 | f"contain only ASCII letters, numbers, underscores, and "
1788 | f"periods."
1789 | )
1790 |
1791 | if self.metadata is None:
1792 | raise ValueError(
1793 | "METADATA file is not present in the archive or is corrupted."
1794 | )
1795 |
1796 | if self.wheeldata is None:
1797 | raise ValueError(
1798 | "WHEEL file is not present in the archive or is corrupted."
1799 | )
1800 |
1801 | # TODO: make this optional
1802 | if self.record is None:
1803 | raise ValueError(
1804 | "RECORD file is not present in the archive or is corrupted."
1805 | )
1806 |
1807 | if self.wheeldata.build != self.build_tag:
1808 | raise ValueError(
1809 | "WHEEL build tag is different than the one in the filename"
1810 | )
1811 |
1812 | # TODO: return a list of defects & negligences present in the wheel file
1813 | # TODO: maybe it's a good idea to put it outside this class?
1814 | # TODO: The implementation could be made simpler by utilizng an internal
1815 | # list of error & lint objects, that have facilities to check a WheelFile
1816 | # object and fix it.
1817 | def lint(self):
1818 | raise NotImplementedError()
1819 |
1820 | # TODO: fix everything we can without guessing
1821 | # TODO: provide sensible defaults
1822 | # TODO: return proper filename
1823 | # TODO: base the fixes on the return value of lint()?
1824 | def fix(self) -> str:
1825 | # Requires rewrite feature
1826 | raise NotImplementedError()
1827 |
1828 | # TODO: ensure RECORD is correct, if it exists
1829 | # TODO: for the first wrong record found, return its arcpath
1830 | # TODO: for the first file not found in the record, return its arcpath
1831 | # TODO: docstring
1832 | def testwheel(self):
1833 | first_broken = self._zip.testzip()
1834 | if first_broken is not None:
1835 | return first_broken
1836 | raise NotImplementedError("Check if RECORD is correct here")
1837 |
1838 | # TODO: if arcname is None, refresh everything (incl. deleted files)
1839 | # TODO: docstring - mention that this does not write record to archive and
1840 | # that the record itself is optional
1841 | # FIXME: this makes basic wheel creation impossible on files with 'wb' mode
1842 | def refresh_record(self, arcname: Union[Path, str]):
1843 | # RECORD file is optional
1844 | if self.record is None:
1845 | return
1846 |
1847 | # Adding directories to record is bad
1848 | if str(arcname).endswith("/"):
1849 | return
1850 |
1851 | if isinstance(arcname, Path):
1852 | arcname = str(arcname)
1853 | if self.closed:
1854 | raise RuntimeError("Cannot refresh record: file closed.")
1855 | # See mypy issue #9917
1856 | assert self._zip.fp.readable(), ( # type: ignore
1857 | "The zipfile stream must be readable in order to generate a record "
1858 | "entry."
1859 | )
1860 | with self._zip.open(arcname) as zf:
1861 | self.record.update(arcname, zf)
1862 |
1863 | def _distinfo_path(self, filename: str, *, kind="dist-info") -> str:
1864 | if self._distinfo_prefix is None:
1865 | name = canonicalize_name(self.distname).replace("-", "_")
1866 | version = str(self.version).replace("-", "_")
1867 | self._distinfo_prefix = f"{name}-{version}."
1868 |
1869 | return f"{self._distinfo_prefix}{kind}/{filename}"
1870 |
1871 | # TODO: lazy mode - do not write anything in lazy mode
1872 | # TODO: use validate(), add possible raised exceptions to the docstring
1873 | # TODO: ensure there are no writing handles open in zipfile before writing
1874 | # meta
1875 | def close(self) -> None:
1876 | """Finalize and close the file.
1877 |
1878 | Writes the rest of the necessary data to the archive, performs one last
1879 | validation of the contents (unless the file is open in lazy mode), and
1880 | closes the file.
1881 |
1882 | There must not be any handles left open for the zip contents, i.e. all
1883 | objects returned by `open()` or `.zip.open()` must be closed before
1884 | calling this subroutine.
1885 |
1886 | Raises
1887 | ------
1888 | RuntimeError
1889 | If there are unclosed content handles.
1890 | """
1891 |
1892 | if self.closed:
1893 | return
1894 |
1895 | if "r" not in self.mode:
1896 | if self.metadata is not None:
1897 | self.writestr(
1898 | self._distinfo_path("METADATA"), str(self.metadata).encode()
1899 | )
1900 | if self.wheeldata is not None:
1901 | self.writestr(
1902 | self._distinfo_path("WHEEL"), str(self.wheeldata).encode()
1903 | )
1904 | self._zip.writestr(self._distinfo_path("RECORD"), str(self.record).encode())
1905 |
1906 | self._zip.close()
1907 |
1908 | def __del__(self):
1909 | try:
1910 | self.close()
1911 | except AttributeError:
1912 | # This may happen if __init__ fails before creating self._zip
1913 | pass
1914 |
1915 | def __enter__(self):
1916 | return self
1917 |
1918 | def __exit__(self, exc_type, exc_val, exc_tb):
1919 | self.close()
1920 |
1921 | @property
1922 | def closed(self) -> bool:
1923 | # ZipFile.fp is set to None upon ZipFile.close()
1924 | return self._zip.fp is None
1925 |
1926 | # TODO: symlinks?
1927 | # TODO: use shutil.copyfileobj same way ZipFile.write does
1928 | def write(
1929 | self,
1930 | filename: Union[str, Path],
1931 | arcname: Optional[str] = None,
1932 | compress_type: Optional[int] = None,
1933 | compresslevel: Optional[int] = None,
1934 | *,
1935 | recursive: bool = True,
1936 | resolve: bool = True,
1937 | skipdir: bool = True,
1938 | ) -> None:
1939 | """Add the file to the wheel.
1940 |
1941 | Updates the wheel record, if the record is being kept.
1942 |
1943 | Parameters
1944 | ----------
1945 | filename
1946 | Path to the file or directory to add.
1947 |
1948 | arcname
1949 | Path in the archive to assign the file/directory into. If not
1950 | given, `filename` will be used instead. In both cases, the leading
1951 | path separators and the drive letter (if any) will be removed.
1952 |
1953 | compress_type
1954 | Same as in `zipfile.ZipFile.write`. Overrides the `compression`
1955 | parameter given to `__init__`.
1956 |
1957 | compresslevel
1958 | Same as in `zipfile.ZipFile.write`. Overrides the `compresslevel`
1959 | parameter given to `__init__`.
1960 |
1961 | recursive
1962 | Keyword only. When True, if given path leads to a directory, all of
1963 | its contents are going to be added into the archive, including
1964 | contents of its subdirectories.
1965 |
1966 | If its False, only a directory entry is going to be added, without
1967 | any of its contents.
1968 |
1969 | resolve
1970 | Keyword only. When True, and no `arcname` is given, the path given
1971 | to `filename` will not be used as the arcname (as is the case with
1972 | `ZipFile.write`), but only the name of the file that it points to
1973 | will be used.
1974 |
1975 | For example, if you set `filename` to `../some/other/dir/file`,
1976 | `file` entry will be written in the archive root.
1977 |
1978 | Has no effect when set to False or when `arcname` is given.
1979 |
1980 | skipdir
1981 | Keyword only. Indicates whether directory entries should be skipped
1982 | in the archive. Set to `True` by default, which means that
1983 | attempting to write an empty directory will be silently omitted.
1984 | """
1985 | if resolve and arcname is None:
1986 | arcname = resolved(filename)
1987 | self._write_to_zip(filename, arcname, skipdir, compress_type, compresslevel)
1988 |
1989 | if recursive:
1990 | common_root = str(filename)
1991 | root_arcname = arcname
1992 | for root, dirs, files in os.walk(filename):
1993 | # For reproducibility, sort directories, so that os.walk
1994 | # traverses them in a defined order.
1995 | dirs.sort()
1996 |
1997 | dirs = [d + "/" for d in dirs]
1998 | for name in sorted(dirs + files):
1999 | filepath = os.path.join(root, name)
2000 | arcpath = self._os_walk_path_to_arcpath(
2001 | common_root, root, name, root_arcname
2002 | )
2003 | self._write_to_zip(
2004 | filepath, arcpath, skipdir, compress_type, compresslevel
2005 | )
2006 |
2007 | def _write_to_zip(self, filename, arcname, skipdir, compress_type, compresslevel):
2008 | zinfo = zipfile.ZipInfo.from_file(
2009 | filename, arcname, strict_timestamps=self._strict_timestamps
2010 | )
2011 |
2012 | # Since we construct ZipInfo manually here, we have to propagate
2013 | # defaults ourselves.
2014 | if compress_type is None:
2015 | compress_type = self.zipfile.compression
2016 | if compresslevel is None:
2017 | compresslevel = self.zipfile.compresslevel
2018 |
2019 | if zinfo.is_dir():
2020 | if skipdir:
2021 | return
2022 | else:
2023 | data = b""
2024 | else:
2025 | with open(filename, "br") as f:
2026 | data = f.read()
2027 | self._zip.writestr(zinfo, data, compress_type, compresslevel)
2028 | self.refresh_record(zinfo.filename)
2029 |
2030 | @staticmethod
2031 | def _os_walk_path_to_arcpath(
2032 | prefix: str, directory: str, stem: str, arcname: Optional[str]
2033 | ):
2034 | if arcname is None:
2035 | arcname = prefix
2036 |
2037 | # Ensure that os.path.join will not get an absolute path after cutting
2038 | # 'prefix' out of 'directory'.
2039 | # Otherwise cutting out 'prefix' might've left out leading '/', which
2040 | # would make os.path.join below ignore 'arcname'.
2041 | prefix = prefix.rstrip(os.sep) + os.sep
2042 |
2043 | path = os.path.join(arcname, directory[len(prefix) :], stem)
2044 | return path
2045 |
2046 | def writestr(
2047 | self,
2048 | zinfo_or_arcname: Union[zipfile.ZipInfo, str],
2049 | data: Union[bytes, str],
2050 | compress_type: Optional[int] = None,
2051 | compresslevel: Optional[int] = None,
2052 | ) -> None:
2053 | """Write given data into the wheel under the given path.
2054 |
2055 | Updates the wheel record, if the record is being kept.
2056 |
2057 | Parameters
2058 | ----------
2059 | zinfo_or_arcname
2060 | Specifies the path in the archive under which the data will be
2061 | stored.
2062 |
2063 | data
2064 | The data that will be writen into the archive. If it's a string, it
2065 | is encoded as UTF-8 first.
2066 |
2067 | compress_type
2068 | Same as in `zipfile.ZipFile.writestr`. Overrides the `compression`
2069 | parameter given to `__init__`. If the first parameter is a
2070 | `ZipInfo` object, the value its `compress_type` field is also
2071 | overriden.
2072 |
2073 | compresslevel
2074 | Same as in `zipfile.ZipFile.writestr`. Overrides the `compresslevel`
2075 | parameter given to `__init__`. If the first parameter is a
2076 | `ZipInfo` object, the value its `compresslevel` field is also
2077 | overriden.
2078 | """
2079 |
2080 | # XXX: ZipFile.writestr() does not normalize arcpaths the same way
2081 | # ZipFile.write() does, and so this method won't do that either
2082 |
2083 | arcname = (
2084 | zinfo_or_arcname.filename
2085 | if isinstance(zinfo_or_arcname, zipfile.ZipInfo)
2086 | else zinfo_or_arcname
2087 | )
2088 |
2089 | self._zip.writestr(zinfo_or_arcname, data, compress_type, compresslevel)
2090 | self.refresh_record(arcname)
2091 |
2092 | # TODO: drive letter should be stripped from the arcname the same way
2093 | # ZipInfo.from_file does it
2094 | # TODO: symlinks?
2095 | def write_data(
2096 | self,
2097 | filename: Union[str, Path],
2098 | section: str,
2099 | arcname: Optional[str] = None,
2100 | compress_type: Optional[int] = None,
2101 | compresslevel: Optional[int] = None,
2102 | *,
2103 | recursive: bool = True,
2104 | resolve: bool = True,
2105 | skipdir: bool = True,
2106 | ) -> None:
2107 | """Write a file to the .data directory under a specified section.
2108 |
2109 | This method is a handy shortcut for writing into
2110 | `-.data/`, such that you dont have to generate the path
2111 | yourself.
2112 |
2113 | Updates the wheel record, if the record is being kept.
2114 |
2115 | Parameters
2116 | ----------
2117 | filename
2118 | Path to the file or directory to add.
2119 |
2120 | section
2121 | Name of the section, i.e. the directory inside `.data/` that the
2122 | file should be put into. Sections have special meaning, see PEP-427.
2123 | Cannot contain any slashes, nor be empty.
2124 |
2125 | arcname
2126 | Path in the archive to assign the file/directory into, relative to
2127 | the directory of the specified data section. If left empty,
2128 | filename is used. Leading slashes are stripped.
2129 |
2130 | compress_type
2131 | Same as in `zipfile.ZipFile.write`. Overrides the `compression`
2132 | parameter given to `__init__`.
2133 |
2134 | compresslevel
2135 | Same as in `zipfile.ZipFile.write`. Overrides the `compresslevel`
2136 | parameter given to `__init__`.
2137 |
2138 | recursive
2139 | Keyword only. When True, if given path leads to a directory, all of
2140 | its contents are going to be added into the archive, including
2141 | contents of its subdirectories.
2142 |
2143 | If its False, only a directory entry is going to be added, without
2144 | any of tis contents.
2145 |
2146 | resolve
2147 | Keyword only. When True, and no `arcname` is given, the path given
2148 | to `filename` will not be used as the arcname (as is the case with
2149 | `ZipFile.write`), but only the name of the file that it points to
2150 | will be used.
2151 |
2152 | For example, if you set `filename` to `../some/other/dir/file`,
2153 | `file` entry will be written in the archive root.
2154 |
2155 | Has no effect when set to False or when `arcname` is given.
2156 |
2157 | skipdir
2158 | Keyword only. Indicates whether directory entries should be skipped
2159 | in the archive. Set to `True` by default, which means that
2160 | attempting to write an empty directory will be silently omitted.
2161 | """
2162 | self._check_section(section)
2163 |
2164 | if isinstance(filename, str):
2165 | filename = Path(filename)
2166 | if arcname is None:
2167 | arcname = filename.name
2168 |
2169 | arcname = self._distinfo_path(section + "/" + arcname.lstrip("/"), kind="data")
2170 |
2171 | self.write(
2172 | filename,
2173 | arcname,
2174 | compress_type,
2175 | compresslevel,
2176 | recursive=recursive,
2177 | resolve=resolve,
2178 | skipdir=skipdir,
2179 | )
2180 |
2181 | # TODO: drive letter should be stripped from the arcname the same way
2182 | # ZipInfo.from_file does it
2183 | def writestr_data(
2184 | self,
2185 | section: str,
2186 | zinfo_or_arcname: Union[zipfile.ZipInfo, str],
2187 | data: Union[bytes, str],
2188 | compress_type: Optional[int] = None,
2189 | compresslevel: Optional[int] = None,
2190 | ) -> None:
2191 | """Write given data to the .data directory under a specified section.
2192 |
2193 | This method is a handy shortcut for writing into
2194 | `-.data/`, such that you dont have to generate the path
2195 | yourself.
2196 |
2197 | Updates the wheel record, if the record is being kept.
2198 |
2199 | Parameters
2200 | ----------
2201 | section
2202 | Name of the section, i.e. the directory inside `.data/` that the
2203 | file should be put into. Sections have special meaning, see PEP-427.
2204 | Cannot contain any slashes, nor be empty.
2205 |
2206 | zinfo_or_arcname
2207 | Specifies the path in the archive under which the data will be
2208 | stored. This is relative to the path of the section directory.
2209 | Leading slashes are stripped.
2210 |
2211 | data
2212 | The data that will be writen into the archive. If it's a string, it
2213 | is encoded as UTF-8 first.
2214 |
2215 | compress_type
2216 | Same as in `zipfile.ZipFile.writestr`. Overrides the `compression`
2217 | parameter given to `__init__`. If the first parameter is a
2218 | `ZipInfo` object, the value its `compress_type` field is also
2219 | overriden.
2220 |
2221 | compresslevel
2222 | Same as in `zipfile.ZipFile.writestr`. Overrides the `compresslevel`
2223 | parameter given to `__init__`. If the first parameter is a
2224 | `ZipInfo` object, the value its `compresslevel` field is also
2225 | overriden.
2226 | """
2227 | self._check_section(section)
2228 |
2229 | arcname = (
2230 | zinfo_or_arcname.filename
2231 | if isinstance(zinfo_or_arcname, zipfile.ZipInfo)
2232 | else zinfo_or_arcname
2233 | )
2234 |
2235 | data_arcname = self._distinfo_path(
2236 | section + "/" + arcname.lstrip("/"), kind="data"
2237 | )
2238 |
2239 | if isinstance(zinfo_or_arcname, zipfile.ZipInfo):
2240 | zinfo_or_arcname = _clone_zipinfo(zinfo_or_arcname, filename=data_arcname)
2241 | else:
2242 | zinfo_or_arcname = data_arcname
2243 |
2244 | self.writestr(zinfo_or_arcname, data, compress_type, compresslevel)
2245 |
2246 | # TODO: Lazy mode should permit writing meta here
2247 | def write_distinfo(
2248 | self,
2249 | filename: Union[str, Path],
2250 | arcname: Optional[str] = None,
2251 | compress_type: Optional[int] = None,
2252 | compresslevel: Optional[int] = None,
2253 | *,
2254 | recursive: bool = True,
2255 | resolve: bool = True,
2256 | skipdir: bool = True,
2257 | ) -> None:
2258 | """Write a file to `.dist-info` directory in the wheel.
2259 |
2260 | This is a shorthand for `write(...)` with `arcname` prefixed with
2261 | the `.dist-info` path. It also ensures that the metadata files critical
2262 | to the wheel correctnes (i.e. the ones written into archive on
2263 | `close()`) aren't being pre-written.
2264 |
2265 | Parameters
2266 | ----------
2267 | filename
2268 | Path to the file or directory to add.
2269 |
2270 | arcname
2271 | Path in the archive to assign the file/directory into. If not
2272 | given, `filename` will be used instead. In both cases, the leading
2273 | path separators and the drive letter (if any) will be removed.
2274 |
2275 | This parameter will be prefixed with proper `.dist-info` path
2276 | automatically.
2277 |
2278 | compress_type
2279 | Same as in `zipfile.ZipFile.write`. Overrides the `compression`
2280 | parameter given to `__init__`.
2281 |
2282 | compresslevel
2283 | Same as in `zipfile.ZipFile.write`. Overrides the `compresslevel`
2284 | parameter given to `__init__`.
2285 |
2286 | recursive
2287 | Keyword only. When True, if given path leads to a directory, all of
2288 | its contents are going to be added into the archive, including
2289 | contents of its subdirectories.
2290 |
2291 | If its False, only a directory entry is going to be added, without
2292 | any of its contents.
2293 |
2294 | resolve
2295 | Keyword only. When True, and no `arcname` is given, the path given
2296 | to `filename` will not be used as the arcname (as is the case with
2297 | `ZipFile.write`), but only the name of the file that it points to
2298 | will be used.
2299 |
2300 | For example, if you set `filename` to `../some/other/dir/file`,
2301 | `file` entry will be written in the `.dist-info` directory.
2302 |
2303 | Has no effect when set to False or when `arcname` is given.
2304 |
2305 | skipdir
2306 | Keyword only. Indicates whether directory entries should be skipped
2307 | in the archive. Set to `True` by default, which means that
2308 | attempting to write an empty directory will be silently omitted.
2309 |
2310 | Raises
2311 | ------
2312 | ProhibitedWriteError
2313 | Raised if the write would result with duplicated `WHEEL`,
2314 | `METADATA`, or `RECORD` files after `close()` is called.
2315 | """
2316 | if resolve and arcname is None:
2317 | arcname = resolved(filename)
2318 | elif arcname is None:
2319 | arcname = str(filename)
2320 |
2321 | if arcname == "":
2322 | raise ProhibitedWriteError(
2323 | "Empty arcname - write would result in duplicating zip entry "
2324 | "for .dist-info directory."
2325 | )
2326 |
2327 | if arcname in self.METADATA_FILENAMES:
2328 | raise ProhibitedWriteError(
2329 | f"Write would result in a duplicated metadata file: {arcname}."
2330 | )
2331 |
2332 | arcname = self._distinfo_path(arcname)
2333 |
2334 | self.write(
2335 | filename,
2336 | arcname,
2337 | compress_type,
2338 | compresslevel,
2339 | recursive=recursive,
2340 | skipdir=skipdir,
2341 | )
2342 |
2343 | def writestr_distinfo(
2344 | self,
2345 | zinfo_or_arcname: Union[zipfile.ZipInfo, str],
2346 | data: Union[bytes, str],
2347 | compress_type: Optional[int] = None,
2348 | compresslevel: Optional[int] = None,
2349 | ) -> None:
2350 | """Write given data to the .dist-info directory.
2351 |
2352 | This method is a handy shortcut for writing into
2353 | `-.dist-info/`, such that you dont have to generate the
2354 | path yourself.
2355 |
2356 | Updates the wheel record, if the record is being kept.
2357 |
2358 | Does not permit writing into arcpaths of metadata files managed by this
2359 | class.
2360 |
2361 | Parameters
2362 | ----------
2363 | zinfo_or_arcname
2364 | Specifies the path in the archive under which the data will be
2365 | stored. This is relative to the path of the section directory.
2366 | Leading slashes are stripped.
2367 |
2368 | data
2369 | The data that will be writen into the archive. If it's a string, it
2370 | is encoded as UTF-8 first.
2371 |
2372 | compress_type
2373 | Same as in `zipfile.ZipFile.writestr`. Overrides the `compression`
2374 | parameter given to `__init__`. If the first parameter is a
2375 | `ZipInfo` object, the value its `compress_type` field is also
2376 | overriden.
2377 |
2378 | compresslevel
2379 | Same as in `zipfile.ZipFile.writestr`. Overrides the `compresslevel`
2380 | parameter given to `__init__`. If the first parameter is a
2381 | `ZipInfo` object, the value its `compresslevel` field is also
2382 | overriden.
2383 |
2384 | Raises
2385 | ------
2386 | ProhibitedWriteError
2387 | When attempting to write into `METADATA`, `WHEEL`, or `RECORD`.
2388 | """
2389 | arcname = (
2390 | zinfo_or_arcname.filename
2391 | if isinstance(zinfo_or_arcname, zipfile.ZipInfo)
2392 | else zinfo_or_arcname
2393 | )
2394 |
2395 | # TODO don't check this in lazy mode
2396 | if (
2397 | arcname in self.METADATA_FILENAMES
2398 | or arcname.split("/")[0] in self.METADATA_FILENAMES
2399 | ):
2400 | raise ProhibitedWriteError(
2401 | f"Write would result in a duplicated metadata file: {arcname}."
2402 | )
2403 |
2404 | dist_arcname = self._distinfo_path(arcname.lstrip("/"))
2405 |
2406 | if isinstance(zinfo_or_arcname, zipfile.ZipInfo):
2407 | zinfo_or_arcname = _clone_zipinfo(zinfo_or_arcname, filename=dist_arcname)
2408 | else:
2409 | zinfo_or_arcname = dist_arcname
2410 |
2411 | self.writestr(zinfo_or_arcname, data, compress_type, compresslevel)
2412 |
2413 | @staticmethod
2414 | def _check_section(section):
2415 | # TODO make sure lazy mode doesn't do this
2416 | if section == "":
2417 | raise ValueError("Section cannot be an empty string.")
2418 | if "/" in section:
2419 | raise ValueError("Section cannot contain slashes.")
2420 |
2421 | def namelist(self) -> List[str]:
2422 | """Return a list of wheel members by name, omit metadata files.
2423 |
2424 | Same as ``ZipFile.namelist()``, but omits ``RECORD``, ``METADATA``, and
2425 | ``WHEEL`` files.
2426 | """
2427 | skip = [self._distinfo_path(n) for n in self.METADATA_FILENAMES]
2428 | return [name for name in self.zipfile.namelist() if name not in skip]
2429 |
2430 | def infolist(self) -> List[zipfile.ZipInfo]:
2431 | """Return a list of ``ZipInfo`` objects for each wheel member.
2432 |
2433 | Same as ``ZipFile.infolist()``, but omits objects corresponding to
2434 | ``RECORD``, ``METADATA``, and ``WHEEL`` files.
2435 | """
2436 | skip = [self._distinfo_path(n) for n in self.METADATA_FILENAMES]
2437 | return [zi for zi in self.zipfile.infolist() if zi.filename not in skip]
2438 |
2439 | @property
2440 | def zipfile(self) -> zipfile.ZipFile:
2441 | return self._zip
2442 |
2443 | # TODO: return a handle w/ record refresh semantics
2444 | def open(self, path) -> IO:
2445 | raise NotImplementedError()
2446 |
--------------------------------------------------------------------------------