├── .git-blame-ignore-revs ├── src └── installer │ ├── py.typed │ ├── _scripts │ ├── t32.exe │ ├── t64.exe │ ├── w32.exe │ ├── w64.exe │ ├── t64-arm.exe │ ├── t_arm.exe │ ├── w64-arm.exe │ ├── w_arm.exe │ └── __init__.py │ ├── __init__.py │ ├── exceptions.py │ ├── __main__.py │ ├── _core.py │ ├── scripts.py │ ├── records.py │ ├── utils.py │ ├── destinations.py │ └── sources.py ├── tests ├── requirements.txt ├── test_scripts.py ├── conftest.py ├── test_main.py ├── test_destinations.py ├── test_utils.py ├── test_records.py ├── test_sources.py └── test_core.py ├── docs ├── requirements.txt ├── license.md ├── api │ ├── __init__.md │ ├── utils.md │ ├── sources.md │ ├── destinations.md │ ├── scripts.md │ └── records.md ├── cli │ └── installer.md ├── _static │ └── custom.css ├── development │ ├── design.md │ ├── index.md │ └── workflow.md ├── index.md ├── concepts.md ├── conf.py └── changelog.md ├── .gitignore ├── .readthedocs.yml ├── README.md ├── .pre-commit-config.yaml ├── LICENSE ├── .github └── workflows │ └── ci.yml ├── pyproject.toml ├── noxfile.py ├── tools └── update_launchers.py └── CONTRIBUTING.md /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/installer/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-cov 3 | pytest-xdist 4 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | furo 3 | myst-parser 4 | sphinx-argparse 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | 3 | .nox/ 4 | /build/ 5 | /dist/ 6 | 7 | .coverage 8 | .*cache 9 | -------------------------------------------------------------------------------- /src/installer/_scripts/t32.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/installer/HEAD/src/installer/_scripts/t32.exe -------------------------------------------------------------------------------- /src/installer/_scripts/t64.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/installer/HEAD/src/installer/_scripts/t64.exe -------------------------------------------------------------------------------- /src/installer/_scripts/w32.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/installer/HEAD/src/installer/_scripts/w32.exe -------------------------------------------------------------------------------- /src/installer/_scripts/w64.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/installer/HEAD/src/installer/_scripts/w64.exe -------------------------------------------------------------------------------- /src/installer/_scripts/t64-arm.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/installer/HEAD/src/installer/_scripts/t64-arm.exe -------------------------------------------------------------------------------- /src/installer/_scripts/t_arm.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/installer/HEAD/src/installer/_scripts/t_arm.exe -------------------------------------------------------------------------------- /src/installer/_scripts/w64-arm.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/installer/HEAD/src/installer/_scripts/w64-arm.exe -------------------------------------------------------------------------------- /src/installer/_scripts/w_arm.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/installer/HEAD/src/installer/_scripts/w_arm.exe -------------------------------------------------------------------------------- /src/installer/_scripts/__init__.py: -------------------------------------------------------------------------------- 1 | """Internal package, containing launcher templates for ``installer.scripts``.""" 2 | -------------------------------------------------------------------------------- /docs/license.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | This project's source code and documentation is under the following license: 4 | 5 | ```{include} ../LICENSE 6 | 7 | ``` 8 | -------------------------------------------------------------------------------- /src/installer/__init__.py: -------------------------------------------------------------------------------- 1 | """A library for installing Python wheels.""" 2 | 3 | __version__ = "1.0.0.dev0" 4 | __all__ = ["install"] 5 | 6 | from installer._core import install 7 | -------------------------------------------------------------------------------- /docs/api/__init__.md: -------------------------------------------------------------------------------- 1 | ```{caution} 2 | This API is not finalised, and may change in a patch version. 3 | ``` 4 | 5 | # `installer` 6 | 7 | ```{eval-rst} 8 | .. autofunction:: installer.install 9 | ``` 10 | -------------------------------------------------------------------------------- /docs/api/utils.md: -------------------------------------------------------------------------------- 1 | ```{caution} 2 | This API is not finalised, and may change in a patch version. 3 | ``` 4 | 5 | # `installer.utils` 6 | 7 | ```{eval-rst} 8 | .. automodule:: installer.utils 9 | :members: 10 | ``` 11 | -------------------------------------------------------------------------------- /docs/api/sources.md: -------------------------------------------------------------------------------- 1 | ```{caution} 2 | This API is not finalised, and may change in a patch version. 3 | ``` 4 | 5 | # `installer.sources` 6 | 7 | ```{eval-rst} 8 | .. automodule:: installer.sources 9 | :members: 10 | ``` 11 | -------------------------------------------------------------------------------- /docs/cli/installer.md: -------------------------------------------------------------------------------- 1 | # `python -m installer` 2 | 3 | This interface allows you to install one or more wheels into a Python 4 | interpreter. 5 | 6 | ```{argparse} 7 | :module: installer.__main__ 8 | :func: _get_main_parser 9 | :prog: python -m installer 10 | ``` 11 | -------------------------------------------------------------------------------- /src/installer/exceptions.py: -------------------------------------------------------------------------------- 1 | """Errors raised from this package.""" 2 | 3 | 4 | class InstallerError(Exception): 5 | """All exceptions raised from this package's code.""" 6 | 7 | 8 | class InvalidWheelSource(InstallerError): 9 | """When a wheel source violates a contract, or is not supported.""" 10 | -------------------------------------------------------------------------------- /docs/api/destinations.md: -------------------------------------------------------------------------------- 1 | ```{caution} 2 | This API is not finalised, and may change in a patch version. 3 | ``` 4 | 5 | # `installer.destinations` 6 | 7 | ```{eval-rst} 8 | .. automodule:: installer.destinations 9 | 10 | .. autoclass:: installer.destinations.WheelDestination 11 | :members: 12 | 13 | .. autoclass:: installer.destinations.SchemeDictionaryDestination() 14 | :members: 15 | :special-members: __init__ 16 | ``` 17 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | # Project page: https://readthedocs.org/projects/installer/ 4 | 5 | version: 2 6 | 7 | sphinx: 8 | builder: dirhtml 9 | configuration: docs/conf.py 10 | 11 | build: 12 | os: ubuntu-22.04 13 | tools: 14 | python: "3" 15 | 16 | python: 17 | install: 18 | - requirements: docs/requirements.txt 19 | - method: pip 20 | path: . 21 | -------------------------------------------------------------------------------- /docs/_static/custom.css: -------------------------------------------------------------------------------- 1 | /*** GENERAL ***/ 2 | 3 | /* Make inline code blocks nicer to look at */ 4 | code.literal { 5 | border-radius: 0.3em; 6 | padding: 0em 0.3em; 7 | } 8 | 9 | div.highlight pre { 10 | border-radius: 0.2em; 11 | padding: 0.75em; 12 | margin: 0 -0.5em; 13 | } 14 | 15 | /*** API REFERENCE ***/ 16 | 17 | /* Space things out properly */ 18 | dl > dd:last-child { 19 | margin-bottom: 10px; 20 | } 21 | 22 | /* Add a tiny dash of color to names of things */ 23 | dt > .property { 24 | color: #a02000; 25 | } 26 | .sig-name, 27 | .sig-prename { 28 | color: #0066bb; 29 | } 30 | -------------------------------------------------------------------------------- /docs/api/scripts.md: -------------------------------------------------------------------------------- 1 | ```{caution} 2 | This API is not finalised, and may change in a patch version. 3 | ``` 4 | 5 | # `installer.scripts` 6 | 7 | Provides the ability to generate executable launcher scripts, that are based on 8 | [`simple_launcher`]. A description of how these scripts work is available in 9 | simple_launcher's README. 10 | 11 | [`simple_launcher`]: https://bitbucket.org/vinay.sajip/simple_launcher/ 12 | 13 | ```{eval-rst} 14 | .. autoclass:: installer.scripts.InvalidScript() 15 | 16 | .. autoclass:: installer.scripts.Script() 17 | :special-members: __init__ 18 | :members: 19 | ``` 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # installer 2 | 3 | 4 | 5 | This is a low-level library for installing a Python package from a 6 | [wheel distribution](https://packaging.python.org/glossary/#term-Wheel). It 7 | provides basic functionality and abstractions for handling wheels and installing 8 | packages from wheels. 9 | 10 | - Logic for "unpacking" a wheel (i.e. installation). 11 | - Abstractions for various parts of the unpacking process. 12 | - Extensible simple implementations of the abstractions. 13 | - Platform-independent Python script wrapper generation. 14 | 15 | 16 | 17 | You can read more in the [documentation](https://installer.rtfd.io/). 18 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/mirrors-mypy 3 | rev: v1.18.2 4 | hooks: 5 | - id: mypy 6 | additional_dependencies: 7 | - httpx 8 | exclude: docs/.*|tests/.*|noxfile.py 9 | 10 | - repo: https://github.com/pre-commit/pre-commit-hooks 11 | rev: v6.0.0 12 | hooks: 13 | - id: check-builtin-literals 14 | - id: check-added-large-files 15 | - id: check-case-conflict 16 | - id: check-toml 17 | - id: check-yaml 18 | - id: debug-statements 19 | - id: end-of-file-fixer 20 | - id: forbid-new-submodules 21 | - id: trailing-whitespace 22 | 23 | - repo: https://github.com/adamchainz/blacken-docs 24 | rev: 1.20.0 25 | hooks: 26 | - id: blacken-docs 27 | 28 | - repo: https://github.com/astral-sh/ruff-pre-commit 29 | rev: v0.14.3 30 | hooks: 31 | - id: ruff 32 | - id: ruff-format 33 | -------------------------------------------------------------------------------- /docs/development/design.md: -------------------------------------------------------------------------------- 1 | # Design and Scope 2 | 3 | ## What this is for 4 | 5 | This project is born out of [this discussion][1]. Effectively, the volunteers 6 | who maintain the Python packaging toolchain identified a need for a library in 7 | the ecology that handles the details of "wheel -> installed package". This is 8 | that library. 9 | 10 | There's also a need for “a fast tool to populate a package into an environment” 11 | and this library can be used to build that. This package itself might also 12 | "grow" a CLI, to provide just that functionality. 13 | 14 | [1]: https://discuss.python.org/t/3869/ 15 | 16 | ## What is provided 17 | 18 | - Abstractions for installation of a wheel distribution. 19 | - Utilities for writing concrete implementations of these abstractions. 20 | - Concrete implementations of these abstraction, for the most common usecase. 21 | - Utilities for handling wheel RECORD files. 22 | - Utilities for generating Python script launchers. 23 | -------------------------------------------------------------------------------- /docs/development/index.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | Thank you for your interest in installer! ✨ 4 | 5 | installer is a volunteer maintained open source project, and we welcome 6 | contributions of all forms. This section of installer's documentation serves as 7 | a resource to help you to contribute to the project. 8 | 9 | ```{toctree} 10 | :hidden: 11 | 12 | workflow 13 | design 14 | ``` 15 | 16 | 17 | [Code of Conduct] 18 | : Applies within all community spaces. If you are not familiar with our Code of Conduct, take a minute to read it before starting with your first contribution. 19 | 20 | [Workflow](./workflow) 21 | : Describes how to work on this project. Start here if you're a new contributor. 22 | 23 | [Design and Scope](./design) 24 | : Describes what this project is for, and how that informs the design decisions made. 25 | 26 | 27 | [code of conduct]: https://github.com/pypa/.github/blob/main/CODE_OF_CONDUCT.md 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Pradyun Gedam 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 11 | all 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 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /docs/api/records.md: -------------------------------------------------------------------------------- 1 | ```{caution} 2 | This API is not finalised, and may change in a patch version. 3 | ``` 4 | 5 | # `installer.records` 6 | 7 | ```{eval-rst} 8 | .. automodule:: installer.records 9 | ``` 10 | 11 | ## Example 12 | 13 | ```{doctest} pycon 14 | >>> from installer.records import parse_record_file, RecordEntry 15 | >>> lines = [ 16 | ... "file.py,sha256=AVTFPZpEKzuHr7OvQZmhaU3LvwKz06AJw8mT\\_pNh2yI,3144", 17 | ... "distribution-1.0.dist-info/RECORD,,", 18 | ... ] 19 | >>> records = parse_record_file(lines) 20 | >>> li = list(records) 21 | >>> len(li) 22 | 2 23 | >>> record_tuple = li[0] 24 | >>> record_tuple 25 | ('file.py', 'sha256=AVTFPZpEKzuHr7OvQZmhaU3LvwKz06AJw8mT\\_pNh2yI', '3144') 26 | >>> record = RecordEntry.from_elements(*record_tuple) 27 | >>> record 28 | RecordEntry(path='file.py', hash_=Hash(name='sha256', value='AVTFPZpEKzuHr7OvQZmhaU3LvwKz06AJw8mT\\_pNh2yI'), size=3144) 29 | >>> record.path 30 | 'file.py' 31 | >>> record.hash_ 32 | Hash(name='sha256', value='AVTFPZpEKzuHr7OvQZmhaU3LvwKz06AJw8mT\\_pNh2yI') 33 | >>> record.size 34 | 3144 35 | >>> record.validate(b"...") 36 | False 37 | ``` 38 | 39 | ## Reference 40 | 41 | ```{eval-rst} 42 | .. autofunction:: installer.records.parse_record_file 43 | 44 | .. autoclass:: installer.records.RecordEntry() 45 | :special-members: __init__ 46 | :members: 47 | 48 | .. autoclass:: installer.records.Hash() 49 | :special-members: __init__ 50 | :members: 51 | ``` 52 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | env: 6 | FORCE_COLOR: 1 7 | 8 | concurrency: 9 | # prettier-ignore 10 | group: >- 11 | ${{ github.workflow }}- 12 | ${{ github.ref_type }}- 13 | ${{ github.event.pull_request.number || github.sha }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | tests: 18 | name: tests / ${{ matrix.os }} / ${{ matrix.python-version }} 19 | runs-on: ${{ matrix.os }}-latest 20 | 21 | strategy: 22 | matrix: 23 | os: [Windows, Ubuntu, MacOS] 24 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] 25 | include: 26 | # Only run PyPy jobs, on Ubuntu. 27 | - os: Ubuntu 28 | python-version: pypy3.10 29 | 30 | steps: 31 | - uses: actions/checkout@v4 32 | 33 | # Get Python to test against 34 | - uses: actions/setup-python@v5 35 | with: 36 | python-version: ${{ matrix.python-version }} 37 | allow-prereleases: true 38 | cache: pip 39 | 40 | - run: pip install nox 41 | 42 | # prettier-ignore 43 | - run: > 44 | nox 45 | -s test-${{ matrix.python-version }} 46 | doctest-${{ matrix.python-version }} 47 | --error-on-missing-interpreters 48 | if: matrix.python-version != 'pypy3.10' 49 | 50 | - run: nox --error-on-missing-interpreters -s test-pypy3 doctest-pypy3 51 | if: matrix.python-version == 'pypy3.10' 52 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide-toc: true 3 | --- 4 | 5 | # Welcome to installer's documentation 6 | 7 | ```{include} ../README.md 8 | :start-after: 9 | :end-before: 10 | ``` 11 | 12 | ```{toctree} 13 | :hidden: 14 | 15 | concepts 16 | ``` 17 | 18 | ```{toctree} 19 | :caption: API reference 20 | :hidden: 21 | :glob: 22 | 23 | api/* 24 | ``` 25 | 26 | ```{toctree} 27 | :caption: CLI reference 28 | :hidden: 29 | :glob: 30 | 31 | cli/* 32 | ``` 33 | 34 | ```{toctree} 35 | :caption: Project 36 | :hidden: 37 | 38 | development/index 39 | changelog 40 | license 41 | GitHub 42 | PyPI 43 | ``` 44 | 45 | ## Basic Usage 46 | 47 | ```python 48 | import sys 49 | import sysconfig 50 | 51 | from installer import install 52 | from installer.destinations import SchemeDictionaryDestination 53 | from installer.sources import WheelFile 54 | 55 | # Handler for installation directories and writing into them. 56 | destination = SchemeDictionaryDestination( 57 | sysconfig.get_paths(), 58 | interpreter=sys.executable, 59 | script_kind="posix", 60 | ) 61 | 62 | with WheelFile.open("sampleproject-1.3.1-py2.py3-none-any.whl") as source: 63 | install( 64 | source=source, 65 | destination=destination, 66 | # Additional metadata that is generated by the installation tool. 67 | additional_metadata={ 68 | "INSTALLER": b"amazing-installer 0.1.0", 69 | }, 70 | ) 71 | ``` 72 | -------------------------------------------------------------------------------- /docs/concepts.md: -------------------------------------------------------------------------------- 1 | # Concepts 2 | 3 | This library has two main abstractions: 4 | 5 | - {any}`WheelSource`: Serves as source of information about a wheel file. 6 | - {any}`WheelDestination`: Handles all file writing and post-installation 7 | processing. 8 | 9 | ## WheelSource 10 | 11 | These objects represent a wheel file, abstracting away how the actual file is 12 | stored or accessed. 13 | 14 | This allows the core install logic to be used with in-memory wheel files, or 15 | unzipped-on-disk wheel, or with {any}`zipfile.ZipFile` objects from an on-disk 16 | wheel, or something else entirely. 17 | 18 | This protocol/abstraction is designed to be implementable without a direct 19 | dependency on this library. This allows for other libraries in the Python 20 | packaging ecosystem to provide implementations of the protocol, allowing for 21 | more code reuse opportunities. 22 | 23 | One of the benefits of this fully described interface is the possibility to 24 | decouple the implementation of additional validation on wheels (such as 25 | validating the RECORD entries in a wheel match the actual contents of the wheel, 26 | or enforcing signing requirements) based on what the specific usecase demands. 27 | 28 | ## WheelDestination 29 | 30 | These objects are responsible for handling the writing-to-filesystem 31 | interactions, determining RECORD file entries and post-install actions (like 32 | generating .pyc files). While this is a lot of responsibility, this was 33 | explicitly provided to make it possible for custom `WheelDestination` 34 | implementations to be more powerful and flexible. 35 | 36 | Most of these tasks can either be delegated to utilities provided in this 37 | library (eg: script generation), or to the Python standard library (eg: 38 | generating `.pyc` files). 39 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | """A sphinx documentation configuration file. 2 | """ 3 | 4 | # -- Project information --------------------------------------------------------------- 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 6 | 7 | project = "installer" 8 | 9 | copyright = "2020, Pradyun Gedam" 10 | author = "Pradyun Gedam" 11 | 12 | # -- General configuration ------------------------------------------------------------- 13 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 14 | 15 | extensions = [ 16 | "sphinx.ext.autodoc", 17 | "sphinx.ext.doctest", 18 | "sphinx.ext.intersphinx", 19 | "sphinx.ext.todo", 20 | "myst_parser", 21 | "sphinxarg.ext", 22 | ] 23 | 24 | # -- Options for HTML output ----------------------------------------------------------- 25 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 26 | 27 | html_theme = "furo" 28 | html_title = project 29 | 30 | # -- Options for Autodoc -------------------------------------------------------------- 31 | # https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html#configuration 32 | 33 | autodoc_member_order = "bysource" 34 | autodoc_preserve_defaults = True 35 | 36 | # Keep the type hints outside the function signature, moving them to the 37 | # descriptions of the relevant function/methods. 38 | autodoc_typehints = "description" 39 | 40 | # Don't show the class signature with the class name. 41 | autodoc_class_signature = "separated" 42 | 43 | # -- Options for intersphinx ---------------------------------------------------------- 44 | # https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#configuration 45 | 46 | intersphinx_mapping = { 47 | "python": ("https://docs.python.org/3", None), 48 | "pypug": ("https://packaging.python.org", None), 49 | } 50 | 51 | # -- Options for Markdown files -------------------------------------------------------- 52 | # https://myst-parser.readthedocs.io/en/latest/sphinx/reference.html 53 | 54 | myst_enable_extensions = [ 55 | "colon_fence", 56 | "deflist", 57 | ] 58 | myst_heading_anchors = 3 59 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "flit_core.buildapi" 3 | requires = [ 4 | "flit_core<4,>=3.11", 5 | ] 6 | 7 | [project] 8 | name = "installer" 9 | readme = "README.md" 10 | authors = [ 11 | { name = "Pradyun Gedam", email = "pradyunsg@gmail.com" }, 12 | ] 13 | requires-python = ">=3.9" 14 | license = "MIT" 15 | classifiers = [ 16 | "Programming Language :: Python :: 3 :: Only", 17 | "Programming Language :: Python :: 3.9", 18 | "Programming Language :: Python :: 3.10", 19 | "Programming Language :: Python :: 3.11", 20 | "Programming Language :: Python :: 3.12", 21 | "Programming Language :: Python :: 3.13", 22 | "Programming Language :: Python :: 3.14", 23 | ] 24 | dynamic = [ 25 | "description", 26 | "version", 27 | ] 28 | [project.urls] 29 | "GitHub" = "https://github.com/pypa/installer" 30 | 31 | [tool.ruff] 32 | fix = true 33 | extend-exclude = [ 34 | "noxfile.py", 35 | "docs/*", 36 | ] 37 | 38 | [tool.ruff.lint] 39 | extend-select = [ 40 | "ERA", # eradicate 41 | "B", # flake8-bugbear 42 | "C4", # flake8-comprehensions 43 | "ISC", # flake8-implicit-str-concat 44 | "PTH", # flake8-use-pathlib 45 | "PIE", # flake8-pie 46 | "T20", # flake8-print 47 | "SIM", # flake8-simplify 48 | "TID", # flake8-tidy-imports 49 | "TC", # flake8-type-checking 50 | "FLY", # flynt 51 | "I", # isort 52 | "N", # pep8-naming 53 | "W", # pycodestyle warnings 54 | "D", # pydocstyle 55 | "PGH", # pygrep-hooks 56 | "UP", # pyupgrade 57 | "RUF", # ruff rules 58 | ] 59 | ignore = [ 60 | "D105", 61 | "D203", 62 | "D213", 63 | # https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules 64 | "W191", 65 | "E111", 66 | "E114", 67 | "E117", 68 | "E501", 69 | "N818", 70 | ] 71 | 72 | [tool.ruff.lint.per-file-ignores] 73 | "tests/*" = ["D"] 74 | "tools/*" = ["D", "T20"] 75 | 76 | [tool.ruff.lint.isort] 77 | known-first-party = ["src"] 78 | 79 | [tool.coverage.report] 80 | exclude_lines = [ 81 | "pragma: no cover", 82 | "def __repr__", 83 | "if TYPE_CHECKING:", 84 | ] 85 | 86 | [tool.mypy] 87 | files = "src,tools" 88 | strict = true 89 | warn_unreachable = true 90 | enable_error_code = [ 91 | "ignore-without-code", 92 | "redundant-expr", 93 | "truthy-bool", 94 | ] 95 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | """Development automation 2 | """ 3 | 4 | import os 5 | 6 | import nox 7 | 8 | nox.options.sessions = ["lint", "test", "doctest"] 9 | nox.options.reuse_existing_virtualenvs = True 10 | 11 | 12 | @nox.session(python="3.12") 13 | def lint(session): 14 | session.install("pre-commit") 15 | 16 | if session.posargs: 17 | args = session.posargs 18 | elif "CI" in os.environ: 19 | args = ["--show-diff-on-failure"] 20 | else: 21 | args = [] 22 | 23 | session.run("pre-commit", "run", "--all-files", *args) 24 | 25 | 26 | @nox.session(python=["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "pypy3"]) 27 | def test(session): 28 | session.install(".") 29 | session.install("-r", "tests/requirements.txt") 30 | 31 | htmlcov_output = os.path.join(session.virtualenv.location, "htmlcov") 32 | 33 | session.run( 34 | "pytest", 35 | "--cov=installer", 36 | "--cov-fail-under=100", 37 | "--cov-report=term-missing", 38 | f"--cov-report=html:{htmlcov_output}", 39 | "--cov-context=test", 40 | "-n", 41 | "auto", 42 | *session.posargs, 43 | ) 44 | 45 | 46 | @nox.session(python=["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "pypy3"]) 47 | def doctest(session): 48 | session.install(".") 49 | session.install("-r", "docs/requirements.txt") 50 | 51 | session.run("sphinx-build", "-b", "doctest", "docs/", "build/doctest") 52 | 53 | 54 | @nox.session(python="3.12", name="update-launchers") 55 | def update_launchers(session): 56 | session.install("httpx") 57 | session.run("python", "tools/update_launchers.py") 58 | 59 | 60 | # 61 | # Documentation 62 | # 63 | @nox.session(python="3.12") 64 | def docs(session): 65 | session.install(".") 66 | session.install("-r", "docs/requirements.txt") 67 | 68 | # Generate documentation into `build/docs` 69 | session.run("sphinx-build", "-W", "-b", "html", "docs/", "build/docs") 70 | 71 | 72 | @nox.session(name="docs-live", python="3.12") 73 | def docs_live(session): 74 | session.install("-e", ".") 75 | session.install("-r", "docs/requirements.txt") 76 | session.install("sphinx-autobuild") 77 | 78 | # fmt: off 79 | session.run( 80 | "sphinx-autobuild", "docs/", "build/docs", 81 | # Rebuild all files when rebuilding 82 | "-a", 83 | # Trigger rebuilds on code changes (for autodoc) 84 | "--watch", "src/installer", 85 | # Use a not-common high-numbered port 86 | "--port", "8765", 87 | ) 88 | # fmt: on 89 | -------------------------------------------------------------------------------- /tests/test_scripts.py: -------------------------------------------------------------------------------- 1 | import io 2 | import zipfile 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | from installer import _scripts 8 | from installer.scripts import InvalidScript, Script 9 | 10 | 11 | def test_script_generate_simple(): 12 | script = Script("foo", "foo.bar", "baz.qux", section="console") 13 | name, data = script.generate("/path/to/my/python", kind="posix") 14 | 15 | assert name == "foo" 16 | assert data.startswith(b"#!/path/to/my/python\n") 17 | assert b"\nfrom foo.bar import baz\n" in data 18 | assert b"baz.qux()" in data 19 | 20 | 21 | def test_script_generate_space_in_executable(): 22 | script = Script("foo", "foo.bar", "baz.qux", section="console") 23 | name, data = script.generate("/path to my/python", kind="posix") 24 | 25 | assert name == "foo" 26 | assert data.startswith(b"#!/bin/sh\n") 27 | assert b" '/path to my/python'" in data 28 | assert b"\nfrom foo.bar import baz\n" in data 29 | assert b"baz.qux()" in data 30 | 31 | 32 | def _read_launcher_data(section, kind): 33 | prefix = {"console": "t", "gui": "w"}[section] 34 | suffix = {"win-ia32": "32", "win-amd64": "64", "win-arm": "_arm"}[kind] 35 | file = Path(_scripts.__file__).parent / f"{prefix}{suffix}.exe" 36 | return file.read_bytes() 37 | 38 | 39 | @pytest.mark.parametrize("section", ["console", "gui"]) 40 | @pytest.mark.parametrize("kind", ["win-ia32", "win-amd64", "win-arm"]) 41 | def test_script_generate_launcher(section, kind): 42 | launcher_data = _read_launcher_data(section, kind) 43 | 44 | script = Script("foo", "foo.bar", "baz.qux", section=section) 45 | name, data = script.generate("#!C:\\path to my\\python.exe\n", kind=kind) 46 | 47 | prefix_len = len(launcher_data) + len(b"#!C:\\path to my\\python.exe\n") 48 | stream = io.BytesIO(data[prefix_len:]) 49 | with zipfile.ZipFile(stream) as zf: 50 | code = zf.read("__main__.py") 51 | 52 | assert name == "foo.exe" 53 | assert data.startswith(launcher_data) 54 | if section == "gui": 55 | assert b"#!C:\\path to my\\pythonw.exe\n" in data 56 | else: 57 | assert b"#!C:\\path to my\\python.exe\n" in data 58 | assert b"\nfrom foo.bar import baz\n" in code 59 | assert b"baz.qux()" in code 60 | 61 | 62 | @pytest.mark.parametrize( 63 | "section, kind", 64 | [("nonexist", "win-ia32"), ("console", "nonexist"), ("nonexist", "nonexist")], 65 | ) 66 | def test_script_generate_launcher_error(section, kind): 67 | script = Script("foo", "foo.bar", "baz.qux", section=section) 68 | with pytest.raises(InvalidScript): 69 | script.generate("#!C:\\path to my\\python.exe\n", kind=kind) 70 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.7.0 (Mar 17, 2023) 4 | 5 | - Improve handling of non-normalized `.dist-info` folders (#168) 6 | - Explicitly use `policy=compat32` (#163) 7 | - Normalize `RECORD` file paths when parsing (#152) 8 | - Search wheels for `.dist-info` directories (#137) 9 | - Separate validation of `RECORD` (#147, #167) 10 | 11 | ## v0.6.0 (Dec 7, 2022) 12 | 13 | - Add support for Python 3.11 (#154) 14 | - Encode hashes in `RECORD` files correctly (#141) 15 | - Add `py.typed` marker file (#138) 16 | - Implement `--prefix` option (#103) 17 | - Fix the unbound `is_executable` (#115) 18 | - Construct `RECORD` file using `csv.writer` (#118) 19 | - Move away from `import installer.xyz` style imports (#110) 20 | - Improve existing documentation content (typos, formatting) (#109) 21 | 22 | ## v0.5.1 (Mar 11, 2022) 23 | 24 | - Change all names in `installer.__main__` to be underscore prefixed. 25 | - Update project URL after move to the `pypa` organisation. 26 | - Rewrite imports to be compatible with `vendoring`. 27 | 28 | ## v0.5.0 (Feb 16, 2022) 29 | 30 | - Add a CLI, to install a wheel into the currently-running Python. 31 | - Convert Windows paths to `/` separated when writing `RECORD`. 32 | - Drop support for Python 3.6 and lower. 33 | - Preserve the executable bit from wheels being installed. 34 | - Write records in `RECORD` with relative paths. 35 | - Improve API documentation. 36 | 37 | ## v0.4.0 (Oct 13, 2021) 38 | 39 | - Pass schemes into {any}`WheelDestination.finalize_installation`. 40 | 41 | ## v0.3.0 (Oct 11, 2021) 42 | 43 | - Add support for ARM 64 executables on Windows. 44 | - Improve handling of wheels that contain entries for directories. 45 | 46 | ## v0.2.3 (Jul 29, 2021) 47 | 48 | - Fix entry point handling in {any}`installer.install`. 49 | 50 | ## v0.2.2 (May 15, 2021) 51 | 52 | - Teach {any}`SchemeDictionaryDestination` to create subfolders. 53 | 54 | ## v0.2.1 (May 15, 2021) 55 | 56 | - Change {any}`parse_record_file` to yield the elements as a tuple, instead of 57 | {any}`RecordEntry` objects. 58 | - Implement {any}`WheelFile`, completing the end-to-end wheel installation 59 | pipeline. 60 | - Generate {any}`RecordEntry` for `RECORD` file in the {any}`installer.install`, 61 | instead of requiring every `WheelDestination` implementation to do the exact 62 | same thing. 63 | 64 | ## v0.2.0 (May 3, 2021) 65 | 66 | - Initial release. 67 | 68 | --- 69 | 70 | Thank you to [Dan Ryan] and [Tzu-ping Chung] for the project name on PyPI. The 71 | PyPI releases before 0.2.0 come from and 72 | have been [yanked]. 73 | 74 | [dan ryan]: https://github.com/techalchemy 75 | [tzu-ping chung]: https://github.com/uranusjr 76 | [yanked]: https://www.python.org/dev/peps/pep-0592/#abstract 77 | -------------------------------------------------------------------------------- /tools/update_launchers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import io 5 | import pathlib 6 | import sys 7 | import tarfile 8 | from typing import Any 9 | 10 | import httpx 11 | 12 | DISTLIB_URL = "https://pypi.org/simple/distlib" 13 | VENDOR_DIR = ( 14 | pathlib.Path(__file__) 15 | .parent.parent.joinpath("src", "installer", "_scripts") 16 | .resolve() 17 | ) 18 | 19 | LAUNCHERS = [ 20 | "t32.exe", 21 | "t64.exe", 22 | "t_arm.exe", 23 | "t64-arm.exe", 24 | "w32.exe", 25 | "w64.exe", 26 | "w_arm.exe", 27 | "w64-arm.exe", 28 | ] 29 | 30 | 31 | async def _get_distlib_page(client: httpx.AsyncClient) -> Any: 32 | resp = await client.get( 33 | DISTLIB_URL, 34 | headers={"ACCEPT": "application/vnd.pypi.simple.v1+json"}, 35 | follow_redirects=True, 36 | ) 37 | return resp.json() 38 | 39 | 40 | def _get_link_from_response(json_response: dict[str, Any]) -> tuple[str, str] | None: 41 | version = max(version_str.split(".") for version_str in json_response["versions"]) 42 | filename = f"distlib-{'.'.join(version)}.tar.gz" 43 | for file_info in json_response["files"]: 44 | if file_info["filename"] == filename: 45 | return file_info["url"], filename 46 | return None 47 | 48 | 49 | async def _download_distlib(client: httpx.AsyncClient) -> bytes | None: 50 | distlib_page = await _get_distlib_page(client) 51 | data = None 52 | if pair := _get_link_from_response(distlib_page): 53 | url, filename = pair 54 | print(f" Fetching {filename}") 55 | resp = await client.get(url) 56 | data = await resp.aread() 57 | return data 58 | 59 | 60 | def _get_launcher_path(names: list[str], launcher: str) -> str | None: 61 | if paths := [name for name in names if launcher in name]: 62 | return paths[0] 63 | return None 64 | 65 | 66 | def _unpack_launchers_to_dir(distlib_tar: bytes) -> None: 67 | print("Unpacking launchers") 68 | with tarfile.open(fileobj=io.BytesIO(distlib_tar)) as file: 69 | for launcher_name in LAUNCHERS: 70 | if (path := _get_launcher_path(file.getnames(), launcher_name)) and ( 71 | launcher := file.extractfile(path) 72 | ): 73 | print(f" Unpacking {launcher_name}") 74 | VENDOR_DIR.joinpath(launcher_name).write_bytes(launcher.read()) 75 | 76 | 77 | async def main() -> None: 78 | print(f"Downloading into {VENDOR_DIR} ...") 79 | async with httpx.AsyncClient() as client: 80 | data = await _download_distlib(client) 81 | if data is not None: 82 | _unpack_launchers_to_dir(data) 83 | print("Scripts update failed!") 84 | 85 | 86 | def _patch_windows() -> None: 87 | # https://github.com/encode/httpx/issues/914 88 | if sys.platform.startswith("win"): 89 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 90 | 91 | 92 | if __name__ == "__main__": 93 | _patch_windows() 94 | asyncio.run(main()) 95 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | import zipfile 3 | 4 | import pytest 5 | 6 | 7 | @pytest.fixture 8 | def fancy_wheel(tmp_path): 9 | return mock_wheel(tmp_path, "fancy") 10 | 11 | 12 | @pytest.fixture 13 | def another_fancy_wheel(tmp_path): 14 | return mock_wheel(tmp_path, "another_fancy") 15 | 16 | 17 | def mock_wheel(tmp_path, name): 18 | path = tmp_path / f"{name}-1.0.0-py2.py3-none-any.whl" 19 | files = { 20 | f"{name}/": b"""""", 21 | f"{name}/__init__.py": b"""\ 22 | def main(): 23 | print("I'm fancy.") 24 | """, 25 | f"{name}/__main__.py": b"""\ 26 | if __name__ == "__main__": 27 | from . import main 28 | main() 29 | """, 30 | f"{name}-1.0.0.data/data/{name}/": b"""""", 31 | f"{name}-1.0.0.data/data/{name}/data.py": b"""\ 32 | # put me in data 33 | """, 34 | f"{name}-1.0.0.dist-info/": b"""""", 35 | f"{name}-1.0.0.dist-info/top_level.txt": f"""\ 36 | {name} 37 | """.encode(), 38 | f"{name}-1.0.0.dist-info/entry_points.txt": f"""\ 39 | [console_scripts] 40 | {name} = {name}:main 41 | 42 | [gui_scripts] 43 | {name}-gui = {name}:main 44 | """.encode(), 45 | f"{name}-1.0.0.dist-info/WHEEL": b"""\ 46 | Wheel-Version: 1.0 47 | Generator: magic (1.0.0) 48 | Root-Is-Purelib: true 49 | Tag: py3-none-any 50 | """, 51 | f"{name}-1.0.0.dist-info/METADATA": f"""\ 52 | Metadata-Version: 2.1 53 | Name: {name} 54 | Version: 1.0.0 55 | Summary: A fancy package 56 | Author: Agendaless Consulting 57 | Author-email: nobody@example.com 58 | License: MIT 59 | Keywords: fancy amazing 60 | Platform: UNKNOWN 61 | Classifier: Intended Audience :: Developers 62 | """.encode(), 63 | f"{name}-1.0.0.dist-info/RECORD": f"""\ 64 | {name}/__init__.py,sha256=qZ2qq7xVBAiUFQVv-QBHhdtCUF5p1NsWwSOiD7qdHN0,36 65 | {name}/__main__.py,sha256=Wd4SyWJOIMsHf_5-0oN6aNFwen8ehJnRo-erk2_K-eY,61 66 | {name}-1.0.0.data/data/{name}/data.py,sha256=nuFRUNQF5vP7FWE-v5ysyrrfpIaAvfzSiGOgfPpLOeI,17 67 | {name}-1.0.0.dist-info/top_level.txt,sha256=SW-yrrF_c8KlserorMw54inhLjZ3_YIuLz7fYT4f8ao,6 68 | {name}-1.0.0.dist-info/entry_points.txt,sha256=AxJl21_zgoNWjCfvSkC9u_rWSzGyCtCzhl84n979jCc,75 69 | {name}-1.0.0.dist-info/WHEEL,sha256=1DrXMF1THfnBjsdS5sZn-e7BKcmUn7jnMbShGeZomgc,84 70 | {name}-1.0.0.dist-info/METADATA,sha256=hRhZavK_Y6WqKurFFAABDnoVMjZFBH0NJRjwLOutnJI,236 71 | {name}-1.0.0.dist-info/RECORD,, 72 | """.encode(), 73 | } 74 | 75 | with zipfile.ZipFile(path, "w") as archive: 76 | for name, indented_content in files.items(): 77 | archive.writestr( 78 | name, 79 | textwrap.dedent(indented_content.decode("utf-8")).encode("utf-8"), 80 | ) 81 | 82 | return path 83 | -------------------------------------------------------------------------------- /docs/development/workflow.md: -------------------------------------------------------------------------------- 1 | # Workflow 2 | 3 | This page describes the tooling used during development of this project. It also 4 | serves as a reference for the various commands that you would use when working 5 | on this project. 6 | 7 | ## Overview 8 | 9 | This project uses the [GitHub Flow] for collaboration. The codebase is Python. 10 | 11 | - [flit] is used for automating development tasks. 12 | - [nox] is used for automating development tasks. 13 | - [pre-commit] is used for running the linters. 14 | - [sphinx] is used for generating this documentation. 15 | - [pytest] is used for running the automated tests. 16 | 17 | ## Repository Layout 18 | 19 | The repository layout is pretty standard for a modern pure-Python project. 20 | 21 | - `CODE_OF_CONDUCT.md` 22 | - `LICENSE` 23 | - `README.md` 24 | - `.nox/` -- Generated by [nox]. 25 | - `dist/` -- Generated as part of the release process. 26 | - `docs/` -- Sources for the documentation. 27 | - `src/` 28 | - `installer/` -- Actual source code for the package 29 | - `tests/` -- Automated tests for the package. 30 | - `noxfile.py` -- for [nox]. 31 | - `pyproject.toml` -- for packaging and tooling configuration. 32 | 33 | ## Initial Setup 34 | 35 | To work on this project, you need to have git 2.17+ and Python 3.8+. 36 | 37 | - Clone this project using git: 38 | 39 | ```sh 40 | git clone https://github.com/pypa/installer.git 41 | cd installer 42 | ``` 43 | 44 | - Install the project's main development dependencies: 45 | 46 | ```sh 47 | pip install nox 48 | ``` 49 | 50 | You're all set for working on this project. 51 | 52 | ## Commands 53 | 54 | ### Code Linting 55 | 56 | ```sh 57 | nox -s lint 58 | ``` 59 | 60 | Run the linters, as configured with [pre-commit]. 61 | 62 | ### Testing 63 | 64 | ```sh 65 | nox -s test 66 | ``` 67 | 68 | Run the tests against all supported Python versions, if an interpreter for that 69 | version is available locally. 70 | 71 | ```sh 72 | nox -s test-3.9 73 | ``` 74 | 75 | Run the tests against Python 3.9. It is also possible to specify other supported 76 | Python versions (like `3.12` or `pypy3`). 77 | 78 | ### Documentation 79 | 80 | ```sh 81 | nox -s docs 82 | ``` 83 | 84 | Generate the documentation for installer into the `build/docs` folder. This 85 | (mostly) does the same thing as `nox -s docs-live`, except it invokes 86 | `sphinx-build` instead of [sphinx-autobuild]. 87 | 88 | ```sh 89 | nox -s docs-live 90 | ``` 91 | 92 | Serve this project's documentation locally, using [sphinx-autobuild]. This will 93 | open the generated documentation page in your browser. 94 | 95 | The server also watches for changes made to the documentation (`docs/`), which 96 | will trigger a rebuild. Once the build is completed, server will automagically 97 | reload any open pages using livereload. 98 | 99 | ## Release process 100 | 101 | - Update the changelog. 102 | - Update the version number in `__init__.py`. 103 | - Commit these changes. 104 | - Create a signed git tag. 105 | - Run `flit publish`. 106 | - Update the version number in `__init__.py`. 107 | - Commit these changes. 108 | - Push tag and commits. 109 | 110 | [github flow]: https://guides.github.com/introduction/flow/ 111 | [flit]: https://flit.readthedocs.io/en/stable/ 112 | [nox]: https://nox.readthedocs.io/en/stable/ 113 | [pytest]: https://docs.pytest.org/en/stable/ 114 | [sphinx]: https://www.sphinx-doc.org/en/master/ 115 | [sphinx-autobuild]: https://github.com/executablebooks/sphinx-autobuild 116 | [pre-commit]: https://pre-commit.com/ 117 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for your interest in contributing to installer. We welcome all 4 | contributions and greatly appreciate your effort! 5 | 6 | ## Code of Conduct 7 | 8 | Everyone interacting in the pip project's codebases, issue trackers, chat rooms, 9 | and mailing lists is expected to follow the [PyPA Code of Conduct][coc]. 10 | 11 | [coc]: https://www.pypa.io/en/latest/code-of-conduct/ 12 | 13 | ## Bugs and Feature Requests 14 | 15 | If you have found any bugs or would like to request a new feature, please do 16 | check if there is an existing issue already filed for the same, in the project's 17 | GitHub [issue tracker]. If not, please file a new issue. 18 | 19 | If you want to help out by fixing bugs, choose an open issue in the [issue 20 | tracker] to work on and claim it by posting a comment saying "I would like to work 21 | on this.". Feel free to ask any doubts in the issue thread. 22 | 23 | While working on implementing the feature, please go ahead and file a pull 24 | request. Filing a pull request early allows for getting feedback as early as 25 | possible. 26 | 27 | [issue tracker]: https://github.com/pypa/installer/issues 28 | 29 | ## Pull Requests 30 | 31 | Pull Requests should be small to facilitate easier review. Keep them 32 | self-contained, and limited in scope. Studies have shown that review quality 33 | falls off as patch size grows. Sometimes this will result in many small PRs to 34 | land a single large feature. 35 | 36 | Checklist: 37 | 38 | 1. All pull requests _must_ be made against the `main` branch. 39 | 2. Include tests for any functionality you implement. Any contributions helping 40 | improve existing tests are welcome. 41 | 3. Update documentation as necessary and provide documentation for any new 42 | functionality. 43 | 44 | ## Development 45 | 46 | [nox] is used to simplify invocation and usage of all the tooling used during 47 | development. 48 | 49 | [nox]: https://github.com/theacodes/nox 50 | 51 | ### Code Convention 52 | 53 | This codebase uses the following tools for enforcing a code convention: 54 | 55 | - [ruff] for code formatting and linting 56 | - [mypy] for static type checking 57 | - [pre-commit] for managing all the linters 58 | 59 | To run all the linters: 60 | 61 | ```sh-session 62 | $ nox -s lint 63 | ``` 64 | 65 | [ruff]: https://github.com/astral-sh/ruff 66 | [mypy]: https://github.com/python/mypy 67 | [pre-commit]: https://pre-commit.com/ 68 | 69 | ### Testing 70 | 71 | This codebase uses [pytest] as the testing framework and [coverage] for 72 | generating code coverage metrics. We enforce a strict 100% test coverage policy 73 | for all code contributions, although [code coverage isn't everything]. 74 | 75 | To run all the tests: 76 | 77 | ```sh-session 78 | $ nox -s test 79 | ``` 80 | 81 | nox has been configured to forward any additional arguments it is given to 82 | pytest. This enables the use of [pytest's rich CLI]. 83 | 84 | ``` 85 | $ # Using file name 86 | $ nox -s test -- tests/*.py 87 | $ # Using markers 88 | $ nox -s test -- -m unit 89 | $ # Using keywords 90 | $ nox -s test -- -k "basic" 91 | ``` 92 | 93 | [pytest]: https://docs.pytest.org/en/stable/ 94 | [coverage]: https://coverage.readthedocs.io/ 95 | [code coverage isn't everything]: 96 | https://bryanpendleton.blogspot.com/2011/02/code-coverage-isnt-everything-but-its.html 97 | [pytest's rich cli]: 98 | https://docs.pytest.org/en/latest/usage.html#specifying-tests-selecting-tests 99 | 100 | ### Documentation 101 | 102 | This codebase uses [Sphinx] for generating documentation. 103 | 104 | To build the documentation: 105 | 106 | ```sh-session 107 | $ nox -s docs 108 | ``` 109 | 110 | [sphinx]: https://www.sphinx-doc.org/ 111 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from installer.__main__ import _get_scheme_dict as get_scheme_dict 6 | from installer.__main__ import _main as main 7 | 8 | 9 | def test_get_scheme_dict(): 10 | d = get_scheme_dict(distribution_name="foo") 11 | assert set(d.keys()) >= {"purelib", "platlib", "headers", "scripts", "data"} 12 | 13 | 14 | def test_get_scheme_dict_prefix(): 15 | d = get_scheme_dict(distribution_name="foo", prefix="/foo") 16 | for key in ("purelib", "platlib", "headers", "scripts", "data"): 17 | assert d[key].startswith(f"{os.sep}foo"), ( 18 | f"{key} does not start with /foo: {d[key]}" 19 | ) 20 | 21 | 22 | def test_main(fancy_wheel, tmp_path): 23 | destdir = tmp_path / "dest" 24 | 25 | main([str(fancy_wheel), "-d", str(destdir)], "python -m installer") 26 | 27 | installed_py_files = destdir.rglob("*.py") 28 | 29 | assert {f.stem for f in installed_py_files} == {"__init__", "__main__", "data"} 30 | 31 | installed_pyc_files = destdir.rglob("*.pyc") 32 | assert {f.name.split(".")[0] for f in installed_pyc_files} == { 33 | "__init__", 34 | "__main__", 35 | } 36 | 37 | 38 | def test_main_multiple_wheels(fancy_wheel, another_fancy_wheel, tmp_path): 39 | destdir = tmp_path / "dest" 40 | 41 | main( 42 | [str(fancy_wheel), str(another_fancy_wheel), "-d", str(destdir)], 43 | "python -m installer", 44 | ) 45 | 46 | for wheel_name in ("fancy", "another_fancy"): 47 | installed_py_files = destdir.rglob(f"**/{wheel_name}/**/*.py") 48 | assert {f.stem for f in installed_py_files} == {"__init__", "__main__", "data"} 49 | 50 | installed_pyc_files = destdir.rglob(f"**/{wheel_name}/**/*.pyc") 51 | assert {f.name.split(".")[0] for f in installed_pyc_files} == { 52 | "__init__", 53 | "__main__", 54 | } 55 | 56 | 57 | def test_main_prefix(fancy_wheel, tmp_path): 58 | destdir = tmp_path / "dest" 59 | 60 | main([str(fancy_wheel), "-d", str(destdir), "-p", "/foo"], "python -m installer") 61 | 62 | installed_py_files = list(destdir.rglob("*.py")) 63 | 64 | for f in installed_py_files: 65 | assert str(f.parent).startswith(str(destdir / "foo")), ( 66 | f"path does not respect destdir+prefix: {f}" 67 | ) 68 | assert {f.stem for f in installed_py_files} == {"__init__", "__main__", "data"} 69 | 70 | installed_pyc_files = destdir.rglob("*.pyc") 71 | assert {f.name.split(".")[0] for f in installed_pyc_files} == { 72 | "__init__", 73 | "__main__", 74 | } 75 | 76 | 77 | def test_main_no_pyc(fancy_wheel, tmp_path): 78 | destdir = tmp_path / "dest" 79 | 80 | main( 81 | [str(fancy_wheel), "-d", str(destdir), "--no-compile-bytecode"], 82 | "python -m installer", 83 | ) 84 | 85 | installed_py_files = destdir.rglob("*.py") 86 | 87 | assert {f.stem for f in installed_py_files} == {"__init__", "__main__", "data"} 88 | 89 | installed_pyc_files = destdir.rglob("*.pyc") 90 | assert set(installed_pyc_files) == set() 91 | 92 | 93 | @pytest.mark.parametrize( 94 | "validation_part", 95 | ["all", "entries", "none"], 96 | ) 97 | def test_main_validate_record_all_pass(fancy_wheel, tmp_path, validation_part): 98 | destdir = tmp_path / "dest" 99 | 100 | main( 101 | [str(fancy_wheel), "-d", str(destdir), "--validate-record", validation_part], 102 | "python -m installer", 103 | ) 104 | 105 | installed_py_files = destdir.rglob("*.py") 106 | 107 | assert {f.stem for f in installed_py_files} == {"__init__", "__main__", "data"} 108 | 109 | installed_pyc_files = destdir.rglob("*.pyc") 110 | assert {f.name.split(".")[0] for f in installed_pyc_files} == { 111 | "__init__", 112 | "__main__", 113 | } 114 | -------------------------------------------------------------------------------- /src/installer/__main__.py: -------------------------------------------------------------------------------- 1 | """Installer CLI.""" 2 | 3 | import argparse 4 | import os.path 5 | import sys 6 | import sysconfig 7 | from collections.abc import Sequence 8 | from typing import Optional 9 | 10 | import installer 11 | from installer.destinations import SchemeDictionaryDestination 12 | from installer.sources import WheelFile 13 | from installer.utils import get_launcher_kind 14 | 15 | 16 | def _get_main_parser() -> argparse.ArgumentParser: 17 | """Construct the main parser.""" 18 | parser = argparse.ArgumentParser() 19 | parser.add_argument("wheel", type=str, nargs="+", help="wheel file to install") 20 | parser.add_argument( 21 | "--destdir", 22 | "-d", 23 | metavar="path", 24 | type=str, 25 | help="destination directory (prefix to prepend to each file)", 26 | ) 27 | parser.add_argument( 28 | "--prefix", 29 | "-p", 30 | metavar="path", 31 | type=str, 32 | help="override prefix to install packages to", 33 | ) 34 | parser.add_argument( 35 | "--compile-bytecode", 36 | action="append", 37 | metavar="level", 38 | type=int, 39 | choices=[0, 1, 2], 40 | help="generate bytecode for the specified optimization level(s) (default=0, 1)", 41 | ) 42 | parser.add_argument( 43 | "--no-compile-bytecode", 44 | action="store_true", 45 | help="don't generate bytecode for installed modules", 46 | ) 47 | parser.add_argument( 48 | "--validate-record", 49 | metavar="part", 50 | default="none", 51 | type=str, 52 | choices=["all", "entries", "none"], 53 | help="validate the wheel against certain part of its record (default=none)", 54 | ) 55 | parser.add_argument( 56 | "--overwrite-existing", 57 | action="store_true", 58 | help="silently overwrite existing files", 59 | ) 60 | return parser 61 | 62 | 63 | def _get_scheme_dict( 64 | distribution_name: str, prefix: Optional[str] = None 65 | ) -> dict[str, str]: 66 | """Calculate the scheme dictionary for the current Python environment.""" 67 | vars = {} 68 | if prefix is None: 69 | installed_base = sysconfig.get_config_var("base") 70 | assert installed_base 71 | else: 72 | vars["base"] = vars["platbase"] = installed_base = prefix 73 | 74 | scheme_dict = sysconfig.get_paths(vars=vars) 75 | 76 | # calculate 'headers' path, not currently in sysconfig - see 77 | # https://bugs.python.org/issue44445. This is based on what distutils does. 78 | # TODO: figure out original vs normalised distribution names 79 | scheme_dict["headers"] = os.path.join( # noqa: PTH118 80 | sysconfig.get_path("include", vars={"installed_base": installed_base}), 81 | distribution_name, 82 | ) 83 | 84 | return scheme_dict 85 | 86 | 87 | def _main(cli_args: Sequence[str], program: Optional[str] = None) -> None: 88 | """Process arguments and perform the install.""" 89 | parser = _get_main_parser() 90 | if program: 91 | parser.prog = program 92 | args = parser.parse_args(cli_args) 93 | 94 | bytecode_levels = args.compile_bytecode 95 | if args.no_compile_bytecode: 96 | bytecode_levels = [] 97 | elif not bytecode_levels: 98 | bytecode_levels = [0, 1] 99 | 100 | for wheel in args.wheel: 101 | with WheelFile.open(wheel) as source: 102 | if args.validate_record != "none": 103 | source.validate_record(validate_contents=args.validate_record == "all") 104 | destination = SchemeDictionaryDestination( 105 | scheme_dict=_get_scheme_dict(source.distribution, prefix=args.prefix), 106 | interpreter=sys.executable, 107 | script_kind=get_launcher_kind(), 108 | bytecode_optimization_levels=bytecode_levels, 109 | destdir=args.destdir, 110 | overwrite_existing=args.overwrite_existing, 111 | ) 112 | installer.install(source, destination, {}) 113 | 114 | 115 | if __name__ == "__main__": # pragma: no cover 116 | _main(sys.argv[1:], "python -m installer") 117 | -------------------------------------------------------------------------------- /src/installer/_core.py: -------------------------------------------------------------------------------- 1 | """Core wheel installation logic.""" 2 | 3 | import posixpath 4 | from io import BytesIO 5 | from typing import cast 6 | 7 | from installer.destinations import WheelDestination 8 | from installer.exceptions import InvalidWheelSource 9 | from installer.records import RecordEntry 10 | from installer.sources import WheelSource 11 | from installer.utils import SCHEME_NAMES, Scheme, parse_entrypoints, parse_metadata_file 12 | 13 | __all__ = ["install"] 14 | 15 | 16 | def _process_WHEEL_file(source: WheelSource) -> Scheme: # noqa: N802 17 | """Process the WHEEL file, from ``source``. 18 | 19 | Returns the scheme that the archive root should go in. 20 | """ 21 | stream = source.read_dist_info("WHEEL") 22 | metadata = parse_metadata_file(stream) 23 | 24 | # Ensure compatibility with this wheel version. 25 | if not (metadata["Wheel-Version"] and metadata["Wheel-Version"].startswith("1.")): 26 | message = "Incompatible Wheel-Version {}, only support version 1.x wheels." 27 | raise InvalidWheelSource(source, message.format(metadata["Wheel-Version"])) 28 | 29 | # Determine where archive root should go. 30 | if metadata["Root-Is-Purelib"] == "true": 31 | return cast("Scheme", "purelib") 32 | else: 33 | return cast("Scheme", "platlib") 34 | 35 | 36 | def _determine_scheme( 37 | path: str, source: WheelSource, root_scheme: Scheme 38 | ) -> tuple[Scheme, str]: 39 | """Determine which scheme to place given path in, from source.""" 40 | data_dir = source.data_dir 41 | 42 | # If it's in not `{distribution}-{version}.data`, then it's in root_scheme. 43 | if posixpath.commonprefix([data_dir, path]) != data_dir: 44 | return root_scheme, path 45 | 46 | # Figure out which scheme this goes to. 47 | parts = [] 48 | scheme_name = None 49 | left = path 50 | while True: 51 | left, right = posixpath.split(left) 52 | parts.append(right) 53 | if left == source.data_dir: 54 | scheme_name = right 55 | break 56 | 57 | if scheme_name not in SCHEME_NAMES: 58 | msg_fmt = "{path} is not contained in a valid .data subdirectory." 59 | raise InvalidWheelSource(source, msg_fmt.format(path=path)) 60 | 61 | return cast("Scheme", scheme_name), posixpath.join(*reversed(parts[:-1])) 62 | 63 | 64 | def install( 65 | source: WheelSource, 66 | destination: WheelDestination, 67 | additional_metadata: dict[str, bytes], 68 | ) -> None: 69 | """Install wheel described by ``source`` into ``destination``. 70 | 71 | :param source: wheel to install. 72 | :param destination: where to write the wheel. 73 | :param additional_metadata: additional metadata files to generate, usually 74 | generated by the caller. 75 | 76 | """ 77 | root_scheme = _process_WHEEL_file(source) 78 | 79 | # RECORD handling 80 | record_file_path = posixpath.join(source.dist_info_dir, "RECORD") 81 | written_records = [] 82 | 83 | # Write the entry_points based scripts. 84 | if "entry_points.txt" in source.dist_info_filenames: 85 | entrypoints_text = source.read_dist_info("entry_points.txt") 86 | for name, module, attr, section in parse_entrypoints(entrypoints_text): 87 | record = destination.write_script( 88 | name=name, 89 | module=module, 90 | attr=attr, 91 | section=section, 92 | ) 93 | written_records.append((Scheme("scripts"), record)) 94 | 95 | # Write all the files from the wheel. 96 | for record_elements, stream, is_executable in source.get_contents(): 97 | source_record = RecordEntry.from_elements(*record_elements) 98 | path = source_record.path 99 | # Skip the RECORD, which is written at the end, based on this info. 100 | if path == record_file_path: 101 | continue 102 | 103 | # Figure out where to write this file. 104 | scheme, destination_path = _determine_scheme( 105 | path=path, 106 | source=source, 107 | root_scheme=root_scheme, 108 | ) 109 | record = destination.write_file( 110 | scheme=scheme, 111 | path=destination_path, 112 | stream=stream, 113 | is_executable=is_executable, 114 | ) 115 | written_records.append((scheme, record)) 116 | 117 | # Write all the installation-specific metadata 118 | for filename, contents in additional_metadata.items(): 119 | path = posixpath.join(source.dist_info_dir, filename) 120 | 121 | with BytesIO(contents) as other_stream: 122 | record = destination.write_file( 123 | scheme=root_scheme, 124 | path=path, 125 | stream=other_stream, 126 | is_executable=False, 127 | ) 128 | written_records.append((root_scheme, record)) 129 | 130 | written_records.append((root_scheme, RecordEntry(record_file_path, None, None))) 131 | destination.finalize_installation( 132 | scheme=root_scheme, 133 | record_file_path=record_file_path, 134 | records=written_records, 135 | ) 136 | -------------------------------------------------------------------------------- /src/installer/scripts.py: -------------------------------------------------------------------------------- 1 | """Generate executable scripts, on various platforms.""" 2 | 3 | import io 4 | import os 5 | import shlex 6 | import zipfile 7 | from collections.abc import Mapping 8 | from dataclasses import dataclass, field 9 | from importlib.resources import files 10 | from typing import TYPE_CHECKING, Optional 11 | 12 | from installer import _scripts 13 | 14 | if TYPE_CHECKING: 15 | from typing import Literal 16 | 17 | LauncherKind = Literal["posix", "win-ia32", "win-amd64", "win-arm", "win-arm64"] 18 | ScriptSection = Literal["console", "gui"] 19 | 20 | 21 | __all__ = ["InvalidScript", "Script"] 22 | 23 | 24 | _ALLOWED_LAUNCHERS: Mapping[tuple["ScriptSection", "LauncherKind"], str] = { 25 | ("console", "win-ia32"): "t32.exe", 26 | ("console", "win-amd64"): "t64.exe", 27 | ("console", "win-arm"): "t_arm.exe", 28 | ("console", "win-arm64"): "t64-arm.exe", 29 | ("gui", "win-ia32"): "w32.exe", 30 | ("gui", "win-amd64"): "w64.exe", 31 | ("gui", "win-arm"): "w_arm.exe", 32 | ("gui", "win-arm64"): "w64-arm.exe", 33 | } 34 | 35 | _SCRIPT_TEMPLATE = """\ 36 | # -*- coding: utf-8 -*- 37 | import re 38 | import sys 39 | from {module} import {import_name} 40 | if __name__ == "__main__": 41 | sys.argv[0] = re.sub(r"(-script\\.pyw|\\.exe)?$", "", sys.argv[0]) 42 | sys.exit({func_path}()) 43 | """ 44 | 45 | 46 | class InvalidScript(ValueError): 47 | """Raised if the user provides incorrect script section or kind.""" 48 | 49 | 50 | @dataclass 51 | class Script: 52 | """Describes a script based on an entry point declaration.""" 53 | 54 | name: str 55 | """Name of the script.""" 56 | 57 | module: str 58 | """Module path, to load the entry point from.""" 59 | 60 | attr: str 61 | """Final attribute access, for the entry point.""" 62 | 63 | section: "ScriptSection" = field(repr=False) 64 | """ 65 | Denotes the "entry point section" where this was specified. Valid values 66 | are ``"gui"`` and ``"console"``. 67 | """ 68 | 69 | def _get_launcher_data(self, kind: "LauncherKind") -> Optional[bytes]: 70 | if kind == "posix": 71 | return None 72 | key = (self.section, kind) 73 | try: 74 | name = _ALLOWED_LAUNCHERS[key] 75 | except KeyError: 76 | error = f"{key!r} not in {sorted(_ALLOWED_LAUNCHERS)!r}" 77 | raise InvalidScript(error) from None 78 | return (files(_scripts) / name).read_bytes() 79 | 80 | def _get_alternate_executable(self, executable: str, kind: "LauncherKind") -> str: 81 | """Get an alternate executable for the launcher. 82 | 83 | On Windows, when the script section is gui-script, pythonw.exe should be used. 84 | """ 85 | if self.section == "gui" and kind != "posix": 86 | dn, fn = os.path.split(executable) 87 | fn = fn.replace("python", "pythonw") 88 | executable = os.path.join(dn, fn) # noqa: PTH118 89 | return executable 90 | 91 | def generate(self, executable: str, kind: "LauncherKind") -> tuple[str, bytes]: 92 | """Generate a launcher for this script. 93 | 94 | :param executable: Path to the executable to invoke. 95 | :param kind: Which launcher template should be used. 96 | Valid values are ``"posix"``, ``"win-ia32"``, ``"win-amd64"`` and 97 | ``"win-arm"``. 98 | :type kind: str 99 | 100 | :raises InvalidScript: if no appropriate template is available. 101 | :return: The name and contents of the launcher file. 102 | """ 103 | launcher = self._get_launcher_data(kind) 104 | executable = self._get_alternate_executable(executable, kind) 105 | shebang = self._build_shebang(executable, forlauncher=bool(launcher)) 106 | code = _SCRIPT_TEMPLATE.format( 107 | module=self.module, 108 | import_name=self.attr.split(".")[0], 109 | func_path=self.attr, 110 | ).encode("utf-8") 111 | 112 | if launcher is None: 113 | return self.name, shebang + b"\n" + code 114 | 115 | stream = io.BytesIO() 116 | with zipfile.ZipFile(stream, "w") as zf: 117 | zf.writestr("__main__.py", code) 118 | name = f"{self.name}.exe" 119 | data = launcher + shebang + b"\n" + stream.getvalue() 120 | return name, data 121 | 122 | @staticmethod 123 | def _is_executable_simple(executable: bytes) -> bool: 124 | if b" " in executable: 125 | return False 126 | shebang_length = len(executable) + 3 # Prefix #! and newline after. 127 | # According to distlib, Darwin can handle up to 512 characters. But I want 128 | # to avoid platform sniffing to make this as platform-agnostic as possible. 129 | # The "complex" script isn't that bad anyway. 130 | return shebang_length <= 127 131 | 132 | def _build_shebang(self, executable: str, forlauncher: bool) -> bytes: 133 | """Build a shebang line. 134 | 135 | The non-launcher cases are taken directly from distlib's implementation, 136 | which tries its best to account for command length, spaces in path, etc. 137 | 138 | https://bitbucket.org/pypa/distlib/src/58cd5c6/distlib/scripts.py#lines-124 139 | """ 140 | executable_bytes = executable.encode("utf-8") 141 | if forlauncher: # The launcher can just use the command as-is. 142 | return b"#!" + executable_bytes 143 | if self._is_executable_simple(executable_bytes): 144 | return b"#!" + executable_bytes 145 | 146 | # Shebang support for an executable with a space in it is under-specified 147 | # and platform-dependent, so we use a clever hack to generate a script to 148 | # run in ``/bin/sh`` that should work on all reasonably modern platforms. 149 | # Read the following message to understand how the hack works: 150 | # https://github.com/pypa/installer/pull/4#issuecomment-623668717 151 | 152 | quoted = shlex.quote(executable).encode("utf-8") 153 | # I don't understand a lick what this is trying to do. 154 | return b"#!/bin/sh\n'''exec' " + quoted + b' "$0" "$@"\n' + b"' '''" 155 | -------------------------------------------------------------------------------- /tests/test_destinations.py: -------------------------------------------------------------------------------- 1 | import io 2 | from pathlib import Path 3 | 4 | import pytest 5 | 6 | from installer.destinations import SchemeDictionaryDestination, WheelDestination 7 | from installer.records import RecordEntry 8 | from installer.scripts import Script 9 | from installer.utils import SCHEME_NAMES 10 | 11 | 12 | class TestWheelDestination: 13 | def test_takes_no_arguments(self): 14 | WheelDestination() 15 | 16 | def test_raises_not_implemented_error(self): 17 | destination = WheelDestination() 18 | 19 | with pytest.raises(NotImplementedError): 20 | destination.write_script(name=None, module=None, attr=None, section=None) 21 | 22 | with pytest.raises(NotImplementedError): 23 | destination.write_file( 24 | scheme=None, path=None, stream=None, is_executable=False 25 | ) 26 | 27 | with pytest.raises(NotImplementedError): 28 | destination.finalize_installation( 29 | scheme=None, 30 | record_file_path=None, 31 | records=None, 32 | ) 33 | 34 | 35 | class TestSchemeDictionaryDestination: 36 | @pytest.fixture() 37 | def destination(self, tmp_path): 38 | scheme_dict = {} 39 | for scheme in SCHEME_NAMES: 40 | full_path = tmp_path / scheme 41 | if not full_path.exists(): 42 | full_path.mkdir() 43 | scheme_dict[scheme] = str(full_path) 44 | return SchemeDictionaryDestination(scheme_dict, "/my/python", "posix") 45 | 46 | @pytest.fixture() 47 | def destination_overwrite_existing(self, tmp_path): 48 | scheme_dict = {} 49 | for scheme in SCHEME_NAMES: 50 | full_path = tmp_path / scheme 51 | if not full_path.exists(): 52 | full_path.mkdir() 53 | scheme_dict[scheme] = str(full_path) 54 | return SchemeDictionaryDestination( 55 | scheme_dict, "/my/python", "posix", overwrite_existing=True 56 | ) 57 | 58 | @pytest.mark.parametrize( 59 | ("scheme", "path", "data", "expected"), 60 | [ 61 | pytest.param( 62 | "data", "my_data.bin", b"my data", b"my data", id="normal file" 63 | ), 64 | pytest.param( 65 | "data", 66 | "data_folder/my_data.bin", 67 | b"my data", 68 | b"my data", 69 | id="normal file in subfolder", 70 | ), 71 | pytest.param( 72 | "scripts", 73 | "my_script.py", 74 | b"#!python\nmy script", 75 | b"#!/my/python\nmy script", 76 | id="script file", 77 | ), 78 | pytest.param( 79 | "scripts", 80 | "script_folder/my_script.py", 81 | b"#!python\nmy script", 82 | b"#!/my/python\nmy script", 83 | id="script file in subfolder", 84 | ), 85 | ], 86 | ) 87 | def test_write_file(self, destination, scheme, path, data, expected): 88 | record = destination.write_file(scheme, path, io.BytesIO(data), False) 89 | file_data = (Path(destination.scheme_dict[scheme]) / path).read_bytes() 90 | assert file_data == expected 91 | assert record.path == path 92 | 93 | def test_write_record_duplicate(self, destination): 94 | destination.write_file("data", "my_data.bin", io.BytesIO(b"my data"), False) 95 | with pytest.raises(FileExistsError): 96 | destination.write_file("data", "my_data.bin", io.BytesIO(b"my data"), False) 97 | 98 | def test_write_record_duplicate_with_overwrite_existing( 99 | self, destination_overwrite_existing 100 | ): 101 | destination_overwrite_existing.write_file( 102 | "data", "my_data.bin", io.BytesIO(b"my data"), False 103 | ) 104 | destination_overwrite_existing.write_file( 105 | "data", "my_data.bin", io.BytesIO(b"my data"), False 106 | ) 107 | 108 | def test_write_script(self, destination): 109 | script_args = ("my_entrypoint", "my_module", "my_function", "console") 110 | record = destination.write_script(*script_args) 111 | file_path = Path(destination.scheme_dict["scripts"]) / "my_entrypoint" 112 | 113 | assert file_path.is_file() 114 | 115 | file_data = file_path.read_bytes() 116 | _, expected_data = Script(*script_args).generate("/my/python", "posix") 117 | 118 | assert file_data == expected_data 119 | assert record.path == "my_entrypoint" 120 | 121 | def test_finalize_write_record(self, destination): 122 | records = [ 123 | ( 124 | "data", 125 | destination.write_file( 126 | "data", 127 | "my_data1.bin", 128 | io.BytesIO(b"my data 1"), 129 | is_executable=False, 130 | ), 131 | ), 132 | ( 133 | "data", 134 | destination.write_file( 135 | "data", 136 | "my_data2.bin", 137 | io.BytesIO(b"my data 2"), 138 | is_executable=False, 139 | ), 140 | ), 141 | ( 142 | "data", 143 | destination.write_file( 144 | "data", 145 | "my_data3,my_data4.bin", 146 | io.BytesIO(b"my data 3"), 147 | is_executable=False, 148 | ), 149 | ), 150 | ( 151 | "scripts", 152 | destination.write_file( 153 | "scripts", 154 | "my_script", 155 | io.BytesIO(b"my script"), 156 | is_executable=True, 157 | ), 158 | ), 159 | ( 160 | "scripts", 161 | destination.write_file( 162 | "scripts", 163 | "my_script2", 164 | io.BytesIO(b"#!python\nmy script"), 165 | is_executable=False, 166 | ), 167 | ), 168 | ( 169 | "scripts", 170 | destination.write_script( 171 | "my_entrypoint", "my_module", "my_function", "console" 172 | ), 173 | ), 174 | ("purelib", RecordEntry("RECORD", None, None)), 175 | ] 176 | 177 | destination.finalize_installation("purelib", "RECORD", records) 178 | file_path = Path(destination.scheme_dict["purelib"]) / "RECORD" 179 | 180 | data = file_path.read_bytes() 181 | assert data == ( 182 | b"RECORD,,\n" 183 | b"../data/my_data1.bin,sha256=NV0A-M4OPuqTsHjeD6Wth_-UqrpAAAdyplcustFZ8s4,9\n" 184 | b"../data/my_data2.bin,sha256=lP7V8oWLqgyXCbdASNiPdsUogzPUZhht_7F8T5bC3eQ,9\n" 185 | b'"../data/my_data3,my_data4.bin",sha256=18krruu1gr01x-WM_9ChSASoHv0mfRAV6-B2bd9sxpo,9\n' 186 | b"../scripts/my_entrypoint,sha256=_p_9nwmeIeoMBfQ0akhr1KbKn3laDydg0J7cy0Fs6JI,216\n" 187 | b"../scripts/my_script,sha256=M60fWvUSMJkPtw2apUvjWWwOcnRPcVy_zO4-4lpH08o,9\n" 188 | b"../scripts/my_script2,sha256=k9_997kTbTYQm7EXFLclVZL1m2N98rU90QX46XeMvjY,22\n" 189 | ) 190 | -------------------------------------------------------------------------------- /src/installer/records.py: -------------------------------------------------------------------------------- 1 | """Provides an object-oriented model for handling :pep:`376` RECORD files.""" 2 | 3 | import base64 4 | import csv 5 | import hashlib 6 | import os 7 | from collections.abc import Iterable, Iterator 8 | from dataclasses import dataclass 9 | from pathlib import Path 10 | from typing import BinaryIO, Optional, cast 11 | 12 | from installer.utils import copyfileobj_with_hashing, get_stream_length 13 | 14 | __all__ = [ 15 | "Hash", 16 | "InvalidRecordEntry", 17 | "RecordEntry", 18 | "parse_record_file", 19 | ] 20 | 21 | 22 | @dataclass 23 | class InvalidRecordEntry(Exception): 24 | """Raised when a RecordEntry is not valid, due to improper element values or count.""" 25 | 26 | elements: Iterable[str] 27 | issues: Iterable[str] 28 | 29 | def __post_init__(self) -> None: 30 | super().__init__(", ".join(self.issues)) 31 | 32 | 33 | @dataclass 34 | class Hash: 35 | """Represents the "hash" element of a RecordEntry. 36 | 37 | Most consumers should use :py:meth:`Hash.parse` instead, since no 38 | validation or parsing is performed by this constructor. 39 | """ 40 | 41 | name: str 42 | """Name of the hash function.""" 43 | 44 | value: str 45 | """Hashed value.""" 46 | 47 | def __str__(self) -> str: 48 | return f"{self.name}={self.value}" 49 | 50 | def validate(self, data: bytes) -> bool: 51 | """Validate that ``data`` matches this instance. 52 | 53 | :param data: Contents of the file. 54 | :return: Whether ``data`` matches the hashed value. 55 | """ 56 | digest = hashlib.new(self.name, data).digest() 57 | value = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") 58 | return self.value == value 59 | 60 | @classmethod 61 | def parse(cls, h: str) -> "Hash": 62 | """Build a Hash object, from a "name=value" string. 63 | 64 | This accepts a string of the format for the second element in a record, 65 | as described in :pep:`376`. 66 | 67 | Typical usage:: 68 | 69 | Hash.parse("sha256=Y0sCextp4SQtQNU-MSs7SsdxD1W-gfKJtUlEbvZ3i-4") 70 | 71 | :param h: a name=value string 72 | """ 73 | name, value = h.split("=", 1) 74 | return cls(name, value) 75 | 76 | 77 | @dataclass 78 | class RecordEntry: 79 | """Represents a single record in a RECORD file. 80 | 81 | A list of :py:class:`RecordEntry` objects fully represents a RECORD file. 82 | 83 | Most consumers should use :py:meth:`RecordEntry.from_elements`, since no 84 | validation or parsing is performed by this constructor. 85 | """ 86 | 87 | path: str 88 | """File's path.""" 89 | 90 | hash_: Optional[Hash] 91 | """Hash of the file's contents.""" 92 | 93 | size: Optional[int] 94 | """File's size in bytes.""" 95 | 96 | def to_row(self, path_prefix: Optional[str] = None) -> tuple[str, str, str]: 97 | """Convert this into a 3-element tuple that can be written in a RECORD file. 98 | 99 | :param path_prefix: A prefix to attach to the path -- must end in `/` 100 | :return: a (path, hash, size) row 101 | """ 102 | if path_prefix is not None: 103 | assert path_prefix.endswith("/") 104 | path = path_prefix + self.path 105 | else: 106 | path = self.path 107 | 108 | # Convert Windows paths to use / for consistency 109 | if os.sep == "\\": 110 | path = path.replace("\\", "/") # pragma: no cover 111 | 112 | return ( 113 | path, 114 | str(self.hash_ or ""), 115 | str(self.size) if self.size is not None else "", 116 | ) 117 | 118 | def __repr__(self) -> str: 119 | return ( 120 | f"RecordEntry(path={self.path!r}, hash_={self.hash_!r}, size={self.size!r})" 121 | ) 122 | 123 | def __eq__(self, other: object) -> bool: 124 | if not isinstance(other, RecordEntry): 125 | return NotImplemented 126 | return ( 127 | self.path == other.path 128 | and self.hash_ == other.hash_ 129 | and self.size == other.size 130 | ) 131 | 132 | def validate(self, data: bytes) -> bool: 133 | """Validate that ``data`` matches this instance. 134 | 135 | .. attention:: 136 | .. deprecated:: 0.8.0 137 | Use :py:meth:`validate_stream` instead, with ``BytesIO(data)``. 138 | 139 | :param data: Contents of the file corresponding to this instance. 140 | :return: whether ``data`` matches hash and size. 141 | """ 142 | if self.size is not None and len(data) != self.size: 143 | return False 144 | 145 | if self.hash_: 146 | return self.hash_.validate(data) 147 | 148 | return True 149 | 150 | def validate_stream(self, stream: BinaryIO) -> bool: 151 | """Validate that data read from stream matches this instance. 152 | 153 | :param stream: Representing the contents of the file. 154 | :return: Whether data read from stream matches hash and size. 155 | """ 156 | if self.hash_ is not None: 157 | with Path(os.devnull).open("wb") as new_target: 158 | hash_, size = copyfileobj_with_hashing( 159 | stream, cast("BinaryIO", new_target), self.hash_.name 160 | ) 161 | 162 | if self.size is not None and size != self.size: 163 | return False 164 | return self.hash_.value == hash_ 165 | 166 | elif self.size is not None: 167 | assert self.hash_ is None 168 | size = get_stream_length(stream) 169 | return size == self.size 170 | 171 | return True 172 | 173 | @classmethod 174 | def from_elements(cls, path: str, hash_: str, size: str) -> "RecordEntry": 175 | r"""Build a RecordEntry object, from values of the elements. 176 | 177 | Typical usage:: 178 | 179 | for row in parse_record_file(f): 180 | record = RecordEntry.from_elements(row[0], row[1], row[2]) 181 | 182 | Meaning of each element is specified in :pep:`376`. 183 | 184 | :param path: first element (file's path) 185 | :param hash\_: second element (hash of the file's contents) 186 | :param size: third element (file's size in bytes) 187 | :raises InvalidRecordEntry: if any element is invalid 188 | """ 189 | # Validate the passed values. 190 | issues = [] 191 | 192 | if not path: 193 | issues.append("`path` cannot be empty") 194 | 195 | if hash_: 196 | try: 197 | hash_value: Optional[Hash] = Hash.parse(hash_) 198 | except ValueError: 199 | issues.append("`hash` does not follow the required format") 200 | else: 201 | hash_value = None 202 | 203 | if size: 204 | try: 205 | size_value: Optional[int] = int(size) 206 | except ValueError: 207 | issues.append("`size` cannot be non-integer") 208 | else: 209 | size_value = None 210 | 211 | if issues: 212 | raise InvalidRecordEntry(elements=(path, hash_, size), issues=issues) 213 | 214 | return cls(path=path, hash_=hash_value, size=size_value) 215 | 216 | 217 | def parse_record_file(rows: Iterable[str]) -> Iterator[tuple[str, str, str]]: 218 | """Parse a :pep:`376` RECORD. 219 | 220 | Returns an iterable of 3-value tuples, that can be passed to 221 | :any:`RecordEntry.from_elements`. 222 | 223 | :param rows: iterator providing lines of a RECORD (no trailing newlines). 224 | """ 225 | reader = csv.reader(rows, delimiter=",", quotechar='"', lineterminator="\n") 226 | for row_index, elements in enumerate(reader): 227 | if len(elements) != 3: 228 | message = f"Row Index {row_index}: expected 3 elements, got {len(elements)}" 229 | raise InvalidRecordEntry(elements=elements, issues=[message]) 230 | 231 | # Convert Windows paths to use / for consistency 232 | elements[0] = elements[0].replace("\\", "/") 233 | 234 | value = cast("tuple[str, str, str]", tuple(elements)) 235 | yield value 236 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | """Tests for installer.utils""" 2 | 3 | import base64 4 | import hashlib 5 | import textwrap 6 | from email.message import Message 7 | from io import BytesIO 8 | 9 | import pytest 10 | 11 | from installer.records import RecordEntry 12 | from installer.utils import ( 13 | WheelFilename, 14 | canonicalize_name, 15 | construct_record_file, 16 | copyfileobj_with_hashing, 17 | fix_shebang, 18 | get_stream_length, 19 | parse_entrypoints, 20 | parse_metadata_file, 21 | parse_wheel_filename, 22 | ) 23 | 24 | 25 | class TestParseMetadata: 26 | def test_basics(self): 27 | result = parse_metadata_file( 28 | textwrap.dedent( 29 | """\ 30 | Name: package 31 | Version: 1.0.0 32 | Multi-Use-Field: 1 33 | Multi-Use-Field: 2 34 | Multi-Use-Field: 3 35 | """ 36 | ) 37 | ) 38 | assert isinstance(result, Message) 39 | assert result.get("Name") == "package" 40 | assert result.get("version") == "1.0.0" 41 | assert result.get_all("MULTI-USE-FIELD") == ["1", "2", "3"] 42 | 43 | 44 | class TestCanonicalizeDistributionName: 45 | @pytest.mark.parametrize( 46 | "string, expected", 47 | [ 48 | # Noop 49 | ( 50 | "package-1", 51 | "package-1", 52 | ), 53 | # PEP 508 canonicalization 54 | ( 55 | "ABC..12", 56 | "abc-12", 57 | ), 58 | ], 59 | ) 60 | def test_valid_cases(self, string, expected): 61 | got = canonicalize_name(string) 62 | assert expected == got, (expected, got) 63 | 64 | 65 | class TestParseWheelFilename: 66 | @pytest.mark.parametrize( 67 | "string, expected", 68 | [ 69 | # Crafted package name w/ a "complex" version and build tag 70 | ( 71 | "package-1!1.0+abc.7-753-py3-none-any.whl", 72 | WheelFilename("package", "1!1.0+abc.7", "753", "py3-none-any"), 73 | ), 74 | # Crafted package name w/ a "complex" version and no build tag 75 | ( 76 | "package-1!1.0+abc.7-py3-none-any.whl", 77 | WheelFilename("package", "1!1.0+abc.7", None, "py3-none-any"), 78 | ), 79 | # Use real tensorflow wheel names 80 | ( 81 | "tensorflow-2.3.0-cp38-cp38-macosx_10_11_x86_64.whl", 82 | WheelFilename( 83 | "tensorflow", "2.3.0", None, "cp38-cp38-macosx_10_11_x86_64" 84 | ), 85 | ), 86 | ( 87 | "tensorflow-2.3.0-cp38-cp38-manylinux2010_x86_64.whl", 88 | WheelFilename( 89 | "tensorflow", "2.3.0", None, "cp38-cp38-manylinux2010_x86_64" 90 | ), 91 | ), 92 | ( 93 | "tensorflow-2.3.0-cp38-cp38-win_amd64.whl", 94 | WheelFilename("tensorflow", "2.3.0", None, "cp38-cp38-win_amd64"), 95 | ), 96 | ], 97 | ) 98 | def test_valid_cases(self, string, expected): 99 | got = parse_wheel_filename(string) 100 | assert expected == got, (expected, got) 101 | 102 | @pytest.mark.parametrize( 103 | "string", 104 | [ 105 | # Not ".whl" 106 | "pip-20.0.0-py2.py3-none-any.zip", 107 | # No tag 108 | "pip-20.0.0.whl", 109 | # Empty tag 110 | "pip-20.0.0---.whl", 111 | ], 112 | ) 113 | def test_invalid_cases(self, string): 114 | with pytest.raises(ValueError): 115 | parse_wheel_filename(string) 116 | 117 | 118 | class TestCopyFileObjWithHashing: 119 | def test_basic_functionality(self): 120 | data = b"input data is this" 121 | hash_ = ( 122 | base64.urlsafe_b64encode(hashlib.sha256(data).digest()) 123 | .decode("ascii") 124 | .rstrip("=") 125 | ) 126 | size = len(data) 127 | 128 | with BytesIO(data) as source, BytesIO() as dest: 129 | result = copyfileobj_with_hashing(source, dest, hash_algorithm="sha256") 130 | written_data = dest.getvalue() 131 | 132 | assert result == (hash_, size) 133 | assert written_data == data 134 | 135 | 136 | class TestGetStreamLength: 137 | def test_basic_functionality(self): 138 | data = b"input data is this" 139 | size = len(data) 140 | 141 | with BytesIO(data) as source: 142 | result = get_stream_length(source) 143 | 144 | assert result == size 145 | 146 | 147 | class TestScript: 148 | @pytest.mark.parametrize( 149 | ("data", "expected"), 150 | [ 151 | pytest.param( 152 | b"#!python\ntest", 153 | b"#!/my/python\ntest", 154 | id="python", 155 | ), 156 | pytest.param( 157 | b"#!pythonw\ntest", 158 | b"#!/my/python\ntest", 159 | id="pythonw", 160 | ), 161 | pytest.param( 162 | b"#!python something\ntest", 163 | b"#!/my/python\ntest", 164 | id="python-with-args", 165 | ), 166 | pytest.param( 167 | b"#!python", 168 | b"#!/my/python\n", 169 | id="python-no-content", 170 | ), 171 | ], 172 | ) 173 | def test_replace_shebang(self, data, expected): 174 | with BytesIO(data) as source, fix_shebang(source, "/my/python") as stream: 175 | result = stream.read() 176 | assert result == expected 177 | 178 | @pytest.mark.parametrize( 179 | "data", 180 | [ 181 | b"#!py\ntest", 182 | b"#!something\ntest", 183 | b"#something\ntest", 184 | b"#something", 185 | b"something", 186 | ], 187 | ) 188 | def test_keep_data(self, data): 189 | with BytesIO(data) as source, fix_shebang(source, "/my/python") as stream: 190 | result = stream.read() 191 | assert result == data 192 | 193 | 194 | class TestConstructRecord: 195 | def test_construct(self): 196 | raw_records = [ 197 | ("a.py", "", ""), 198 | ("a.py", "", "3144"), 199 | ("a.py", "sha256=AVTFPZpEKzuHr7OvQZmhaU3LvwKz06AJw8mT\\_pNh2yI", ""), 200 | ("a.py", "sha256=AVTFPZpEKzuHr7OvQZmhaU3LvwKz06AJw8mT\\_pNh2yI", "3144"), 201 | ] 202 | records = [ 203 | ("purelib", RecordEntry.from_elements(*elements)) 204 | for elements in raw_records 205 | ] 206 | 207 | assert construct_record_file(records).read() == ( 208 | b"a.py,,\n" 209 | b"a.py,,3144\n" 210 | b"a.py,sha256=AVTFPZpEKzuHr7OvQZmhaU3LvwKz06AJw8mT\\_pNh2yI,\n" 211 | b"a.py,sha256=AVTFPZpEKzuHr7OvQZmhaU3LvwKz06AJw8mT\\_pNh2yI,3144\n" 212 | ) 213 | 214 | 215 | class TestParseEntryPoints: 216 | @pytest.mark.parametrize( 217 | ("script", "expected"), 218 | [ 219 | pytest.param("", [], id="empty"), 220 | pytest.param( 221 | """ 222 | [foo] 223 | foo = foo.bar 224 | """, 225 | [], 226 | id="unrelated", 227 | ), 228 | pytest.param( 229 | """ 230 | [console_scripts] 231 | package = package.__main__:package 232 | """, 233 | [ 234 | ("package", "package.__main__", "package", "console"), 235 | ], 236 | id="cli", 237 | ), 238 | pytest.param( 239 | """ 240 | [gui_scripts] 241 | package = package.__main__:package 242 | """, 243 | [ 244 | ("package", "package.__main__", "package", "gui"), 245 | ], 246 | id="gui", 247 | ), 248 | pytest.param( 249 | """ 250 | [console_scripts] 251 | magic-cli = magic.cli:main 252 | 253 | [gui_scripts] 254 | magic-gui = magic.gui:main 255 | """, 256 | [ 257 | ("magic-cli", "magic.cli", "main", "console"), 258 | ("magic-gui", "magic.gui", "main", "gui"), 259 | ], 260 | id="cli-and-gui", 261 | ), 262 | ], 263 | ) 264 | def test_valid(self, script, expected): 265 | iterable = parse_entrypoints(textwrap.dedent(script)) 266 | assert list(iterable) == expected, expected 267 | -------------------------------------------------------------------------------- /src/installer/utils.py: -------------------------------------------------------------------------------- 1 | """Utilities related to handling / interacting with wheel files.""" 2 | 3 | import base64 4 | import contextlib 5 | import csv 6 | import hashlib 7 | import io 8 | import os 9 | import re 10 | import sys 11 | from collections import namedtuple 12 | from collections.abc import Iterable, Iterator 13 | from configparser import ConfigParser 14 | from email.message import Message 15 | from email.parser import FeedParser 16 | from email.policy import compat32 17 | from pathlib import Path 18 | from typing import ( 19 | TYPE_CHECKING, 20 | BinaryIO, 21 | Callable, 22 | NewType, 23 | Optional, 24 | cast, 25 | ) 26 | 27 | if TYPE_CHECKING: 28 | from installer.records import RecordEntry 29 | from installer.scripts import LauncherKind, ScriptSection 30 | 31 | Scheme = NewType("Scheme", str) 32 | AllSchemes = tuple[Scheme, ...] 33 | 34 | __all__ = [ 35 | "SCHEME_NAMES", 36 | "WheelFilename", 37 | "construct_record_file", 38 | "copyfileobj_with_hashing", 39 | "fix_shebang", 40 | "get_launcher_kind", 41 | "make_file_executable", 42 | "parse_entrypoints", 43 | "parse_metadata_file", 44 | "parse_wheel_filename", 45 | ] 46 | 47 | # Borrowed from https://github.com/python/cpython/blob/v3.9.1/Lib/shutil.py#L52 48 | _WINDOWS = os.name == "nt" 49 | _COPY_BUFSIZE = 1024 * 1024 if _WINDOWS else 64 * 1024 50 | 51 | # According to https://www.python.org/dev/peps/pep-0427/#file-name-convention 52 | _WHEEL_FILENAME_REGEX = re.compile( 53 | r""" 54 | ^ 55 | (?P.+?) 56 | -(?P.*?) 57 | (?:-(?P\d[^-]*?))? 58 | -(?P.+?-.+?-.+?) 59 | \.whl 60 | $ 61 | """, 62 | re.VERBOSE | re.UNICODE, 63 | ) 64 | WheelFilename = namedtuple( 65 | "WheelFilename", ["distribution", "version", "build_tag", "tag"] 66 | ) 67 | 68 | # Adapted from https://github.com/python/importlib_metadata/blob/v3.4.0/importlib_metadata/__init__.py#L90 69 | _ENTRYPOINT_REGEX = re.compile( 70 | r""" 71 | (?P[\w.]+)\s* 72 | (:\s*(?P[\w.]+))\s* 73 | (?P\[.*\])?\s*$ 74 | """, 75 | re.VERBOSE | re.UNICODE, 76 | ) 77 | 78 | # According to https://www.python.org/dev/peps/pep-0427/#id7 79 | SCHEME_NAMES = cast("AllSchemes", ("purelib", "platlib", "headers", "scripts", "data")) 80 | 81 | 82 | def parse_metadata_file(contents: str) -> Message: 83 | """Parse :pep:`376` ``PKG-INFO``-style metadata files. 84 | 85 | ``METADATA`` and ``WHEEL`` files (as per :pep:`427`) use the same syntax 86 | and can also be parsed using this function. 87 | 88 | :param contents: The entire contents of the file 89 | """ 90 | feed_parser = FeedParser(policy=compat32) 91 | feed_parser.feed(contents) 92 | return feed_parser.close() 93 | 94 | 95 | def canonicalize_name(name: str) -> str: 96 | """Canonicalize a project name according to PEP-503. 97 | 98 | :param name: The project name to canonicalize 99 | """ 100 | return re.sub(r"[-_.]+", "-", name).lower() 101 | 102 | 103 | def parse_wheel_filename(filename: str) -> WheelFilename: 104 | """Parse a wheel filename, into it's various components. 105 | 106 | :param filename: The filename to parse 107 | """ 108 | wheel_info = _WHEEL_FILENAME_REGEX.match(filename) 109 | if not wheel_info: 110 | raise ValueError(f"Not a valid wheel filename: {filename}") 111 | return WheelFilename(*wheel_info.groups()) 112 | 113 | 114 | def copyfileobj_with_hashing( 115 | source: BinaryIO, 116 | dest: BinaryIO, 117 | hash_algorithm: str, 118 | ) -> tuple[str, int]: 119 | """Copy a buffer while computing the content's hash and size. 120 | 121 | Copies the source buffer into the destination buffer while computing the 122 | hash of the contents. Adapted from :any:`shutil.copyfileobj`. 123 | 124 | :param source: buffer holding the source data 125 | :param dest: destination buffer 126 | :param hash_algorithm: hashing algorithm 127 | 128 | :return: hash digest of the contents, size of the contents 129 | """ 130 | hasher = hashlib.new(hash_algorithm) 131 | size = 0 132 | while True: 133 | buf = source.read(_COPY_BUFSIZE) 134 | if not buf: 135 | break 136 | hasher.update(buf) 137 | dest.write(buf) 138 | size += len(buf) 139 | 140 | return base64.urlsafe_b64encode(hasher.digest()).decode("ascii").rstrip("="), size 141 | 142 | 143 | def get_stream_length(source: BinaryIO) -> int: 144 | """Read a buffer while computing the content's size. 145 | 146 | :param source: buffer holding the source data 147 | :return: size of the contents 148 | """ 149 | size = 0 150 | while True: 151 | buf = source.read(_COPY_BUFSIZE) 152 | if not buf: 153 | break 154 | size += len(buf) 155 | 156 | return size 157 | 158 | 159 | def get_launcher_kind() -> "LauncherKind": # pragma: no cover 160 | """Get the launcher kind for the current machine.""" 161 | if os.name != "nt": 162 | return "posix" 163 | 164 | if "amd64" in sys.version.lower(): 165 | return "win-amd64" 166 | if "(arm64)" in sys.version.lower(): 167 | return "win-arm64" 168 | if "(arm)" in sys.version.lower(): 169 | return "win-arm" 170 | if sys.platform == "win32": 171 | return "win-ia32" 172 | 173 | raise NotImplementedError("Unknown launcher kind for this machine") 174 | 175 | 176 | @contextlib.contextmanager 177 | def fix_shebang(stream: BinaryIO, interpreter: str) -> Iterator[BinaryIO]: 178 | """Replace ``#!python`` shebang in a stream with the correct interpreter. 179 | 180 | :param stream: stream to modify 181 | :param interpreter: "correct interpreter" to substitute the shebang with 182 | 183 | :returns: A context manager, that provides an appropriately modified stream. 184 | """ 185 | stream.seek(0) 186 | if stream.read(8) == b"#!python": 187 | new_stream = io.BytesIO() 188 | # write our new shebang 189 | new_stream.write(f"#!{interpreter}\n".encode()) 190 | # copy the rest of the stream 191 | stream.seek(0) 192 | stream.readline() # skip first line 193 | while True: 194 | buf = stream.read(_COPY_BUFSIZE) 195 | if not buf: 196 | break 197 | new_stream.write(buf) 198 | new_stream.seek(0) 199 | yield new_stream 200 | new_stream.close() 201 | else: 202 | stream.seek(0) 203 | yield stream 204 | 205 | 206 | def construct_record_file( 207 | records: Iterable[tuple[Scheme, "RecordEntry"]], 208 | prefix_for_scheme: Callable[[Scheme], Optional[str]] = lambda _: None, 209 | ) -> BinaryIO: 210 | """Construct a RECORD file. 211 | 212 | :param records: 213 | ``records`` as passed into :any:`WheelDestination.finalize_installation` 214 | :param prefix_for_scheme: 215 | function to get a prefix to add for RECORD entries, within a scheme 216 | 217 | :return: A stream that can be written to file. Must be closed by the caller. 218 | """ 219 | stream = io.TextIOWrapper( 220 | io.BytesIO(), encoding="utf-8", write_through=True, newline="" 221 | ) 222 | writer = csv.writer(stream, delimiter=",", quotechar='"', lineterminator="\n") 223 | for scheme, record in sorted(records, key=lambda x: x[1].path): 224 | writer.writerow(record.to_row(prefix_for_scheme(scheme))) 225 | stream.seek(0) 226 | return stream.detach() 227 | 228 | 229 | def parse_entrypoints(text: str) -> Iterable[tuple[str, str, str, "ScriptSection"]]: 230 | """Parse ``entry_points.txt``-style files. 231 | 232 | :param text: entire contents of the file 233 | :return: 234 | name of the script, module to use, attribute to call, kind of script (cli / gui) 235 | """ 236 | # Borrowed from https://github.com/python/importlib_metadata/blob/v3.4.0/importlib_metadata/__init__.py#L115 237 | config = ConfigParser(delimiters="=") 238 | config.optionxform = str # type: ignore[assignment, method-assign] 239 | config.read_string(text) 240 | 241 | for section in config.sections(): 242 | if section not in ["console_scripts", "gui_scripts"]: 243 | continue 244 | 245 | for name, value in config.items(section): 246 | assert isinstance(name, str) 247 | match = _ENTRYPOINT_REGEX.match(value) 248 | assert match 249 | 250 | module = match.group("module") 251 | assert isinstance(module, str) 252 | 253 | attrs = match.group("attrs") 254 | # TODO: make this a proper error, which can be caught. 255 | assert attrs is not None 256 | assert isinstance(attrs, str) 257 | 258 | script_section = cast("ScriptSection", section[: -len("_scripts")]) 259 | 260 | yield name, module, attrs, script_section 261 | 262 | 263 | def _current_umask() -> int: 264 | """Get the current umask which involves having to set it temporarily.""" 265 | mask = os.umask(0) 266 | os.umask(mask) 267 | return mask 268 | 269 | 270 | # Borrowed from: 271 | # https://github.com/pypa/pip/blob/0f21fb92/src/pip/_internal/utils/unpacking.py#L93 272 | def make_file_executable(path: Path) -> None: 273 | """Make the file at the provided path executable.""" 274 | path.chmod(0o777 & ~_current_umask() | 0o111) 275 | -------------------------------------------------------------------------------- /src/installer/destinations.py: -------------------------------------------------------------------------------- 1 | """Handles all file writing and post-installation processing.""" 2 | 3 | import io 4 | import os 5 | from collections.abc import Collection, Iterable 6 | from dataclasses import dataclass 7 | from pathlib import Path 8 | from typing import ( 9 | TYPE_CHECKING, 10 | BinaryIO, 11 | Optional, 12 | Union, 13 | ) 14 | 15 | from installer.records import Hash, RecordEntry 16 | from installer.scripts import Script 17 | from installer.utils import ( 18 | Scheme, 19 | construct_record_file, 20 | copyfileobj_with_hashing, 21 | fix_shebang, 22 | make_file_executable, 23 | ) 24 | 25 | if TYPE_CHECKING: 26 | from installer.scripts import LauncherKind, ScriptSection 27 | 28 | 29 | class WheelDestination: 30 | """Handles writing the unpacked files, script generation and ``RECORD`` generation. 31 | 32 | Subclasses provide the concrete script generation logic, as well as the RECORD file 33 | (re)writing. 34 | """ 35 | 36 | def write_script( 37 | self, name: str, module: str, attr: str, section: "ScriptSection" 38 | ) -> RecordEntry: 39 | """Write a script in the correct location to invoke given entry point. 40 | 41 | :param name: name of the script 42 | :param module: module path, to load the entry point from 43 | :param attr: final attribute access, for the entry point 44 | :param section: Denotes the "entry point section" where this was specified. 45 | Valid values are ``"gui"`` and ``"console"``. 46 | :type section: str 47 | 48 | Example usage/behaviour:: 49 | 50 | >>> dest.write_script("pip", "pip._internal.cli", "main", "console") 51 | 52 | """ 53 | raise NotImplementedError 54 | 55 | def write_file( 56 | self, 57 | scheme: Scheme, 58 | path: Union[str, "os.PathLike[str]"], 59 | stream: BinaryIO, 60 | is_executable: bool, 61 | ) -> RecordEntry: 62 | """Write a file to correct ``path`` within the ``scheme``. 63 | 64 | :param scheme: scheme to write the file in (like "purelib", "platlib" etc). 65 | :param path: path within that scheme 66 | :param stream: contents of the file 67 | :param is_executable: whether the file should be made executable 68 | 69 | The stream would be closed by the caller, after this call. 70 | 71 | Example usage/behaviour:: 72 | 73 | >>> with open("__init__.py") as stream: 74 | ... dest.write_file("purelib", "pkg/__init__.py", stream) 75 | 76 | """ 77 | raise NotImplementedError 78 | 79 | def finalize_installation( 80 | self, 81 | scheme: Scheme, 82 | record_file_path: str, 83 | records: Iterable[tuple[Scheme, RecordEntry]], 84 | ) -> None: 85 | """Finalize installation, after all the files are written. 86 | 87 | Handles (re)writing of the ``RECORD`` file. 88 | 89 | :param scheme: scheme to write the ``RECORD`` file in 90 | :param record_file_path: path of the ``RECORD`` file with that scheme 91 | :param records: entries to write to the ``RECORD`` file 92 | 93 | Example usage/behaviour:: 94 | 95 | >>> dest.finalize_installation("purelib") 96 | 97 | """ 98 | raise NotImplementedError 99 | 100 | 101 | @dataclass 102 | class SchemeDictionaryDestination(WheelDestination): 103 | """Destination, based on a mapping of {scheme: file-system-path}.""" 104 | 105 | scheme_dict: dict[str, str] 106 | """A mapping of {scheme: file-system-path}""" 107 | 108 | interpreter: str 109 | """The interpreter to use for generating scripts.""" 110 | 111 | script_kind: "LauncherKind" 112 | """The "kind" of launcher script to use.""" 113 | 114 | hash_algorithm: str = "sha256" 115 | """ 116 | The hashing algorithm to use, which is a member of 117 | :any:`hashlib.algorithms_available` (ideally from 118 | :any:`hashlib.algorithms_guaranteed`). 119 | """ 120 | 121 | bytecode_optimization_levels: Collection[int] = () 122 | """ 123 | Compile cached bytecode for installed .py files with these optimization 124 | levels. The bytecode is specific to the minor version of Python (e.g. 3.10) 125 | used to generate it. 126 | """ 127 | 128 | destdir: Optional[str] = None 129 | """ 130 | A staging directory in which to write all files. This is expected to be the 131 | filesystem root at runtime, so embedded paths will be written as though 132 | this was the root. 133 | """ 134 | 135 | overwrite_existing: bool = False 136 | """Silently overwrite existing files.""" 137 | 138 | def _path_with_destdir(self, scheme: Scheme, path: str) -> Path: 139 | file = Path(self.scheme_dict[scheme]) / path 140 | if self.destdir is not None: 141 | rel_path = file.relative_to(file.anchor) 142 | return Path(self.destdir) / rel_path 143 | return file 144 | 145 | def write_to_fs( 146 | self, 147 | scheme: Scheme, 148 | path: str, 149 | stream: BinaryIO, 150 | is_executable: bool, 151 | ) -> RecordEntry: 152 | """Write contents of ``stream`` to the correct location on the filesystem. 153 | 154 | :param scheme: scheme to write the file in (like "purelib", "platlib" etc). 155 | :param path: path within that scheme 156 | :param stream: contents of the file 157 | :param is_executable: whether the file should be made executable 158 | 159 | - Ensures that an existing file is not being overwritten. 160 | - Hashes the written content, to determine the entry in the ``RECORD`` file. 161 | """ 162 | target_path = self._path_with_destdir(scheme, path) 163 | if not self.overwrite_existing and target_path.exists(): 164 | message = f"File already exists: {target_path!s}" 165 | raise FileExistsError(message) 166 | 167 | parent_folder = target_path.parent 168 | if not parent_folder.exists(): 169 | parent_folder.mkdir(parents=True) 170 | 171 | with target_path.open("wb") as f: 172 | hash_, size = copyfileobj_with_hashing(stream, f, self.hash_algorithm) 173 | 174 | if is_executable: 175 | make_file_executable(target_path) 176 | 177 | return RecordEntry(path, Hash(self.hash_algorithm, hash_), size) 178 | 179 | def write_file( 180 | self, 181 | scheme: Scheme, 182 | path: Union[str, "os.PathLike[str]"], 183 | stream: BinaryIO, 184 | is_executable: bool, 185 | ) -> RecordEntry: 186 | """Write a file to correct ``path`` within the ``scheme``. 187 | 188 | :param scheme: scheme to write the file in (like "purelib", "platlib" etc). 189 | :param path: path within that scheme 190 | :param stream: contents of the file 191 | :param is_executable: whether the file should be made executable 192 | 193 | - Changes the shebang for files in the "scripts" scheme. 194 | - Uses :py:meth:`SchemeDictionaryDestination.write_to_fs` for the 195 | filesystem interaction. 196 | """ 197 | path_ = os.fspath(path) 198 | 199 | if scheme == "scripts": 200 | with fix_shebang(stream, self.interpreter) as stream_with_different_shebang: 201 | return self.write_to_fs( 202 | scheme, path_, stream_with_different_shebang, is_executable 203 | ) 204 | 205 | return self.write_to_fs(scheme, path_, stream, is_executable) 206 | 207 | def write_script( 208 | self, name: str, module: str, attr: str, section: "ScriptSection" 209 | ) -> RecordEntry: 210 | """Write a script to invoke an entrypoint. 211 | 212 | :param name: name of the script 213 | :param module: module path, to load the entry point from 214 | :param attr: final attribute access, for the entry point 215 | :param section: Denotes the "entry point section" where this was specified. 216 | Valid values are ``"gui"`` and ``"console"``. 217 | :type section: str 218 | 219 | - Generates a launcher using :any:`Script.generate`. 220 | - Writes to the "scripts" scheme. 221 | - Uses :py:meth:`SchemeDictionaryDestination.write_to_fs` for the 222 | filesystem interaction. 223 | """ 224 | script = Script(name, module, attr, section) 225 | script_name, data = script.generate(self.interpreter, self.script_kind) 226 | 227 | with io.BytesIO(data) as stream: 228 | entry = self.write_to_fs( 229 | Scheme("scripts"), script_name, stream, is_executable=True 230 | ) 231 | 232 | path = self._path_with_destdir(Scheme("scripts"), script_name) 233 | mode = path.stat().st_mode 234 | mode |= (mode & 0o444) >> 2 235 | path.chmod(mode) 236 | 237 | return entry 238 | 239 | def _compile_bytecode(self, scheme: Scheme, record: RecordEntry) -> None: 240 | """Compile bytecode for a single .py file.""" 241 | if scheme not in ("purelib", "platlib"): 242 | return 243 | 244 | import compileall 245 | 246 | target_path = self._path_with_destdir(scheme, record.path) 247 | dir_path_to_embed = (Path(self.scheme_dict[scheme]) / record.path).parent 248 | 249 | for level in self.bytecode_optimization_levels: 250 | compileall.compile_file( 251 | target_path, optimize=level, quiet=1, ddir=dir_path_to_embed 252 | ) 253 | 254 | def finalize_installation( 255 | self, 256 | scheme: Scheme, 257 | record_file_path: str, 258 | records: Iterable[tuple[Scheme, RecordEntry]], 259 | ) -> None: 260 | """Finalize installation, by writing the ``RECORD`` file & compiling bytecode. 261 | 262 | :param scheme: scheme to write the ``RECORD`` file in 263 | :param record_file_path: path of the ``RECORD`` file with that scheme 264 | :param records: entries to write to the ``RECORD`` file 265 | """ 266 | 267 | def prefix_for_scheme(file_scheme: str) -> Optional[str]: 268 | if file_scheme == scheme: 269 | return None 270 | path = os.path.relpath( 271 | self.scheme_dict[file_scheme], 272 | start=self.scheme_dict[scheme], 273 | ) 274 | return path + "/" 275 | 276 | record_list = list(records) 277 | with construct_record_file(record_list, prefix_for_scheme) as record_stream: 278 | self.write_to_fs( 279 | scheme, record_file_path, record_stream, is_executable=False 280 | ) 281 | 282 | for scheme, record in record_list: 283 | self._compile_bytecode(scheme, record) 284 | -------------------------------------------------------------------------------- /tests/test_records.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | from pathlib import Path 3 | 4 | import pytest 5 | 6 | from installer.records import Hash, InvalidRecordEntry, RecordEntry, parse_record_file 7 | 8 | 9 | # 10 | # pytest fixture witchcraft 11 | # 12 | @pytest.fixture() 13 | def record_simple_list(): 14 | return [ 15 | "file.py,sha256=AVTFPZpEKzuHr7OvQZmhaU3LvwKz06AJw8mT\\_pNh2yI,3144", 16 | "distribution-1.0.dist-info/RECORD,,", 17 | ] 18 | 19 | 20 | @pytest.fixture() 21 | def record_simple_iter(record_simple_list): 22 | return iter(record_simple_list) 23 | 24 | 25 | @pytest.fixture() 26 | def record_simple_file(tmpdir, record_simple_list): 27 | p = tmpdir.join("RECORD") 28 | p.write("\n".join(record_simple_list)) 29 | with Path(p).open() as f: 30 | yield f 31 | 32 | 33 | @pytest.fixture() 34 | def record_input(request): 35 | return request.getfixturevalue(request.param) 36 | 37 | 38 | SAMPLE_RECORDS = [ 39 | ( 40 | "purelib", 41 | ("test1.py", "sha256=Y0sCextp4SQtQNU-MSs7SsdxD1W-gfKJtUlEbvZ3i-4", 6), 42 | b"test1\n", 43 | True, 44 | ), 45 | ( 46 | "purelib", 47 | ("test2.py", "sha256=fW_Xd08Nh2JNptzxbQ09EEwxkedx--LznIau1LK_Gg8", 6), 48 | b"test2\n", 49 | True, 50 | ), 51 | ( 52 | "purelib", 53 | ("test3.py", "sha256=qwPDTx7OCCEf4qgDn9ZCQZmz9de1X_E7ETSzZHdsRcU", 6), 54 | b"test3\n", 55 | True, 56 | ), 57 | ( 58 | "purelib", 59 | ( 60 | "test4.py", 61 | "sha256=Y0sCextp4SQtQNU-MSs7SsdxD1W-gfKJtUlEbvZ3i-4", 62 | None, 63 | ), 64 | b"test1\n", 65 | True, 66 | ), 67 | ("purelib", ("test5.py", None, None), b"test1\n", True), 68 | ("purelib", ("test6.py", None, 6), b"test1\n", True), 69 | ( 70 | "purelib", 71 | ("test7.py", "sha256=Y0sCextp4SQtQNU-MSs7SsdxD1W-gfKJtUlEbvZ3i-4", 7), 72 | b"test1\n", 73 | False, 74 | ), 75 | ( 76 | "purelib", 77 | ("test7.py", "sha256=Y0sCextp4SQtQNU-MSs7SsdxD1W-gfKJtUlEbvZ3i-4", None), 78 | b"not-test1\n", 79 | False, 80 | ), 81 | ("purelib", ("test8.py", None, 10), b"test1\n", False), 82 | ] 83 | 84 | 85 | # 86 | # Actual Tests 87 | # 88 | class TestRecordEntry: 89 | @pytest.mark.parametrize( 90 | "path, hash_, size, caused_by", 91 | [ 92 | ("", "", "", ["path"]), 93 | ("", "", "non-int", ["path", "size"]), 94 | ("a.py", "", "non-int", ["size"]), 95 | # Notice that we're explicitly allowing non-compliant hash values 96 | ("a.py", "some-random-value", "non-int", ["size"]), 97 | ], 98 | ) 99 | def test_invalid_elements(self, path, hash_, size, caused_by): 100 | with pytest.raises(InvalidRecordEntry) as exc_info: 101 | RecordEntry.from_elements(path, hash_, size) 102 | 103 | assert exc_info.value.elements == (path, hash_, size) 104 | for word in caused_by: 105 | assert word in str(exc_info.value) 106 | 107 | @pytest.mark.parametrize( 108 | "path, hash_, size", 109 | [ 110 | ("a.py", "", ""), 111 | ("a.py", "", "3144"), 112 | ("a.py", "sha256=AVTFPZpEKzuHr7OvQZmhaU3LvwKz06AJw8mT\\_pNh2yI", ""), 113 | ("a.py", "sha256=AVTFPZpEKzuHr7OvQZmhaU3LvwKz06AJw8mT\\_pNh2yI", "3144"), 114 | ], 115 | ) 116 | def test_valid_elements(self, path, hash_, size): 117 | RecordEntry.from_elements(path, hash_, size) 118 | 119 | @pytest.mark.parametrize( 120 | ("scheme", "elements", "data", "passes_validation"), SAMPLE_RECORDS 121 | ) 122 | def test_populates_attributes_correctly( 123 | self, scheme, elements, data, passes_validation 124 | ): 125 | path, hash_string, size = elements 126 | 127 | record = RecordEntry.from_elements(path, hash_string, size) 128 | 129 | assert record.path == path 130 | assert record.size == size 131 | 132 | if record.hash_ is not None: 133 | assert isinstance(record.hash_, Hash) 134 | assert record.hash_.name == "sha256" 135 | assert record.hash_.value == hash_string[len("sha256=") :] 136 | 137 | @pytest.mark.parametrize( 138 | ("scheme", "elements", "data", "passes_validation"), SAMPLE_RECORDS 139 | ) 140 | def test_validation(self, scheme, elements, data, passes_validation): 141 | record = RecordEntry.from_elements(*elements) 142 | assert record.validate(data) == passes_validation 143 | 144 | @pytest.mark.parametrize( 145 | ("scheme", "elements", "data", "passes_validation"), SAMPLE_RECORDS 146 | ) 147 | def test_validate_stream(self, scheme, elements, data, passes_validation): 148 | record = RecordEntry.from_elements(*elements) 149 | 150 | assert record.validate_stream(BytesIO(data)) == passes_validation 151 | 152 | @pytest.mark.parametrize( 153 | ("scheme", "elements", "data", "passes_validation"), SAMPLE_RECORDS 154 | ) 155 | def test_string_representation(self, scheme, elements, data, passes_validation): 156 | record = RecordEntry.from_elements(*elements) 157 | 158 | expected_row = tuple( 159 | [(str(elem) if elem is not None else "") for elem in elements] 160 | ) 161 | assert record.to_row() == expected_row 162 | 163 | @pytest.mark.parametrize( 164 | ("scheme", "elements", "data", "passes_validation"), SAMPLE_RECORDS 165 | ) 166 | def test_string_representation_with_prefix( 167 | self, scheme, elements, data, passes_validation 168 | ): 169 | record = RecordEntry.from_elements(*elements) 170 | 171 | expected_row = tuple( 172 | [ 173 | (str(elem) if elem is not None else "") 174 | for elem in ("prefix/" + elements[0], elements[1], elements[2]) 175 | ] 176 | ) 177 | assert record.to_row("prefix/") == expected_row 178 | 179 | def test_equality(self): 180 | record = RecordEntry.from_elements( 181 | "file.py", 182 | "sha256=AVTFPZpEKzuHr7OvQZmhaU3LvwKz06AJw8mT\\_pNh2yI", 183 | "3144", 184 | ) 185 | record_same = RecordEntry.from_elements( 186 | "file.py", 187 | "sha256=AVTFPZpEKzuHr7OvQZmhaU3LvwKz06AJw8mT\\_pNh2yI", 188 | "3144", 189 | ) 190 | record_different_name = RecordEntry.from_elements( 191 | "file2.py", 192 | "sha256=AVTFPZpEKzuHr7OvQZmhaU3LvwKz06AJw8mT\\_pNh2yI", 193 | "3144", 194 | ) 195 | record_different_hash_name = RecordEntry.from_elements( 196 | "file.py", 197 | "md5=AVTFPZpEKzuHr7OvQZmhaU3LvwKz06AJw8mT\\_pNh2yI", 198 | "3144", 199 | ) 200 | record_different_hash_value = RecordEntry.from_elements( 201 | "file.py", 202 | "sha256=qwertyuiodfdsflkgshdlkjghrefawrwerwffsdfflk29", 203 | "3144", 204 | ) 205 | record_different_size = RecordEntry.from_elements( 206 | "file.py", 207 | "sha256=AVTFPZpEKzuHr7OvQZmhaU3LvwKz06AJw8mT\\_pNh2yI", 208 | "10", 209 | ) 210 | 211 | assert record == record_same 212 | 213 | assert record != "random string" 214 | assert record != record_different_name 215 | assert record != record_different_hash_name 216 | assert record != record_different_hash_value 217 | assert record != record_different_size 218 | 219 | # Ensure equality is based on current state 220 | record_same.hash_ = None 221 | assert record != record_same 222 | 223 | 224 | class TestParseRecordFile: 225 | def test_accepts_empty_iterable(self): 226 | list(parse_record_file([])) 227 | 228 | @pytest.mark.parametrize( 229 | "record_input", 230 | ["record_simple_list", "record_simple_iter", "record_simple_file"], 231 | indirect=True, 232 | ) 233 | def test_accepts_all_kinds_of_iterables(self, record_input): 234 | """Should accepts any iterable, e.g. container, iterator, or file object.""" 235 | records = list(parse_record_file(record_input)) 236 | assert len(records) == 2 237 | 238 | assert records == [ 239 | ( 240 | "file.py", 241 | "sha256=AVTFPZpEKzuHr7OvQZmhaU3LvwKz06AJw8mT\\_pNh2yI", 242 | "3144", 243 | ), 244 | ("distribution-1.0.dist-info/RECORD", "", ""), 245 | ] 246 | 247 | @pytest.mark.parametrize( 248 | "line, element_count", 249 | [ 250 | pytest.param( 251 | "file.py,sha256=AVTFPZpEKzuHr7OvQZmhaU3LvwKz06AJw8mT\\_pNh2yI,3144,", 252 | 4, 253 | id="four", 254 | ), 255 | pytest.param( 256 | "distribution-1.0.dist-info/RECORD,,,,", 257 | 5, 258 | id="five", 259 | ), 260 | ], 261 | ) 262 | def test_rejects_wrong_element_count(self, line, element_count): 263 | with pytest.raises(InvalidRecordEntry) as exc_info: 264 | list(parse_record_file([line])) 265 | 266 | message = f"expected 3 elements, got {element_count}" 267 | assert message in str(exc_info.value) 268 | 269 | def test_shows_correct_row_number(self): 270 | record_lines = [ 271 | "file1.py,sha256=AVTFPZpEKzuHr7OvQZmhaU3LvwKz06AJw8mT\\_pNh2yI,3144", 272 | "file2.py,sha256=AVTFPZpEKzuHr7OvQZmhaU3LvwKz06AJw8mT\\_pNh2yI,3144", 273 | "file3.py,sha256=AVTFPZpEKzuHr7OvQZmhaU3LvwKz06AJw8mT\\_pNh2yI,3144", 274 | "distribution-1.0.dist-info/RECORD,,,,", 275 | ] 276 | with pytest.raises(InvalidRecordEntry) as exc_info: 277 | list(parse_record_file(record_lines)) 278 | 279 | assert "Row Index 3" in str(exc_info.value) 280 | 281 | def test_parse_record_entry_with_comma(self): 282 | record_lines = [ 283 | '"file1,file2.txt",sha256=AVTFPZpEKzuHr7OvQZmhaU3LvwKz06AJw8mT\\_pNh2yI,3144', 284 | "distribution-1.0.dist-info/RECORD,,", 285 | ] 286 | records = list(parse_record_file(record_lines)) 287 | assert records == [ 288 | ( 289 | "file1,file2.txt", 290 | "sha256=AVTFPZpEKzuHr7OvQZmhaU3LvwKz06AJw8mT\\_pNh2yI", 291 | "3144", 292 | ), 293 | ("distribution-1.0.dist-info/RECORD", "", ""), 294 | ] 295 | 296 | def test_parse_record_entry_with_backslash_path(self): 297 | record_lines = [ 298 | "distribution-1.0.dist-info\\RECORD,,", 299 | ] 300 | records = list(parse_record_file(record_lines)) 301 | assert records == [ 302 | ("distribution-1.0.dist-info/RECORD", "", ""), 303 | ] 304 | -------------------------------------------------------------------------------- /src/installer/sources.py: -------------------------------------------------------------------------------- 1 | """Source of information about a wheel file.""" 2 | 3 | import posixpath 4 | import stat 5 | import zipfile 6 | from collections.abc import Iterator 7 | from contextlib import contextmanager 8 | from functools import cached_property 9 | from pathlib import Path 10 | from typing import ( 11 | TYPE_CHECKING, 12 | BinaryIO, 13 | ClassVar, 14 | Optional, 15 | cast, 16 | ) 17 | 18 | from installer.exceptions import InstallerError 19 | from installer.records import RecordEntry, parse_record_file 20 | from installer.utils import canonicalize_name, parse_wheel_filename 21 | 22 | if TYPE_CHECKING: 23 | import os 24 | 25 | WheelContentElement = tuple[tuple[str, str, str], BinaryIO, bool] 26 | 27 | 28 | __all__ = ["WheelFile", "WheelSource"] 29 | 30 | 31 | class WheelSource: 32 | """Represents an installable wheel. 33 | 34 | This is an abstract class, whose methods have to be implemented by subclasses. 35 | """ 36 | 37 | validation_error: ClassVar[type[Exception]] = ValueError #: :meta hide-value: 38 | """ 39 | .. versionadded:: 0.7.0 40 | 41 | Exception to be raised by :py:meth:`validate_record` when validation fails. 42 | This is expected to be a subclass of :py:class:`ValueError`. 43 | """ 44 | 45 | def __init__(self, distribution: str, version: str) -> None: 46 | """Initialize a WheelSource object. 47 | 48 | :param distribution: distribution name (like ``urllib3``) 49 | :param version: version associated with the wheel 50 | """ 51 | super().__init__() 52 | self.distribution = distribution 53 | self.version = version 54 | 55 | @property 56 | def dist_info_dir(self) -> str: 57 | """Name of the dist-info directory.""" 58 | return f"{self.distribution}-{self.version}.dist-info" 59 | 60 | @property 61 | def data_dir(self) -> str: 62 | """Name of the data directory.""" 63 | return f"{self.distribution}-{self.version}.data" 64 | 65 | @property 66 | def dist_info_filenames(self) -> list[str]: 67 | """Get names of all files in the dist-info directory. 68 | 69 | Sample usage/behaviour:: 70 | 71 | >>> wheel_source.dist_info_filenames 72 | ['METADATA', 'WHEEL'] 73 | """ 74 | raise NotImplementedError 75 | 76 | def read_dist_info(self, filename: str) -> str: 77 | """Get contents, from ``filename`` in the dist-info directory. 78 | 79 | Sample usage/behaviour:: 80 | 81 | >>> wheel_source.read_dist_info("METADATA") 82 | ... 83 | 84 | :param filename: name of the file 85 | """ 86 | raise NotImplementedError 87 | 88 | def validate_record(self) -> None: 89 | """Validate ``RECORD`` of the wheel. 90 | 91 | .. versionadded:: 0.7.0 92 | 93 | This method should be called before :py:func:`install ` 94 | if validation is required. 95 | """ 96 | raise NotImplementedError 97 | 98 | def get_contents(self) -> Iterator[WheelContentElement]: 99 | """Sequential access to all contents of the wheel (including dist-info files). 100 | 101 | This method should return an iterable. Each value from the iterable must be a 102 | tuple containing 3 elements: 103 | 104 | - record: 3-value tuple, to pass to 105 | :py:meth:`RecordEntry.from_elements `. 106 | - stream: An :py:class:`io.BufferedReader` object, providing the contents of the 107 | file at the location provided by the first element (path). 108 | - is_executable: A boolean, representing whether the item has an executable bit. 109 | 110 | All paths must be relative to the root of the wheel. 111 | 112 | Sample usage/behaviour:: 113 | 114 | >>> iterable = wheel_source.get_contents() 115 | >>> next(iterable) 116 | (('pkg/__init__.py', '', '0'), <...>, False) 117 | 118 | This method may be called multiple times. Each iterable returned must 119 | provide the same content upon reading from a specific file's stream. 120 | """ 121 | raise NotImplementedError 122 | 123 | 124 | class _WheelFileValidationError(ValueError, InstallerError): 125 | """Raised when a wheel file fails validation.""" 126 | 127 | def __init__(self, issues: list[str]) -> None: 128 | super().__init__(repr(issues)) 129 | self.issues = issues 130 | 131 | def __repr__(self) -> str: 132 | return f"WheelFileValidationError(issues={self.issues!r})" 133 | 134 | 135 | class _WheelFileBadDistInfo(ValueError, InstallerError): 136 | """Raised when a wheel file has issues around `.dist-info`.""" 137 | 138 | def __init__(self, *, reason: str, filename: Optional[str], dist_info: str) -> None: 139 | super().__init__(reason) 140 | self.reason = reason 141 | self.filename = filename 142 | self.dist_info = dist_info 143 | 144 | def __str__(self) -> str: 145 | return ( 146 | f"{self.reason} (filename={self.filename!r}, dist_info={self.dist_info!r})" 147 | ) 148 | 149 | 150 | class WheelFile(WheelSource): 151 | """Implements `WheelSource`, for an existing file from the filesystem. 152 | 153 | Example usage:: 154 | 155 | >>> with WheelFile.open("sampleproject-2.0.0-py3-none-any.whl") as source: 156 | ... installer.install(source, destination) 157 | """ 158 | 159 | validation_error = _WheelFileValidationError 160 | 161 | def __init__(self, f: zipfile.ZipFile) -> None: 162 | """Initialize a WheelFile object. 163 | 164 | :param f: An open zipfile, which will stay open as long as this object is used. 165 | """ 166 | self._zipfile = f 167 | assert f.filename 168 | 169 | basename = Path(f.filename).name 170 | parsed_name = parse_wheel_filename(basename) 171 | super().__init__( 172 | version=parsed_name.version, 173 | distribution=parsed_name.distribution, 174 | ) 175 | 176 | @classmethod 177 | @contextmanager 178 | def open(cls, path: "os.PathLike[str]") -> Iterator["WheelFile"]: 179 | """Create a wheelfile from a given path.""" 180 | with zipfile.ZipFile(path) as f: 181 | yield cls(f) 182 | 183 | @cached_property 184 | def dist_info_dir(self) -> str: 185 | """Name of the dist-info directory.""" 186 | top_level_directories = { 187 | path.split("/", 1)[0] for path in self._zipfile.namelist() 188 | } 189 | dist_infos = [ 190 | name for name in top_level_directories if name.endswith(".dist-info") 191 | ] 192 | 193 | try: 194 | (dist_info_dir,) = dist_infos 195 | except ValueError: 196 | raise _WheelFileBadDistInfo( 197 | reason="Wheel doesn't contain exactly one .dist-info directory", 198 | filename=self._zipfile.filename, 199 | dist_info=str(sorted(dist_infos)), 200 | ) from None 201 | 202 | # NAME-VER.dist-info 203 | di_dname = dist_info_dir.rsplit("-", 2)[0] 204 | norm_di_dname = canonicalize_name(di_dname) 205 | norm_file_dname = canonicalize_name(self.distribution) 206 | 207 | if norm_di_dname != norm_file_dname: 208 | raise _WheelFileBadDistInfo( 209 | reason="Wheel .dist-info directory doesn't match wheel filename", 210 | filename=self._zipfile.filename, 211 | dist_info=dist_info_dir, 212 | ) 213 | 214 | return dist_info_dir 215 | 216 | @property 217 | def dist_info_filenames(self) -> list[str]: 218 | """Get names of all files in the dist-info directory.""" 219 | base = self.dist_info_dir 220 | return [ 221 | name[len(base) + 1 :] 222 | for name in self._zipfile.namelist() 223 | if name[-1:] != "/" 224 | if base == posixpath.commonprefix([name, base]) 225 | ] 226 | 227 | def read_dist_info(self, filename: str) -> str: 228 | """Get contents, from ``filename`` in the dist-info directory.""" 229 | path = posixpath.join(self.dist_info_dir, filename) 230 | return self._zipfile.read(path).decode("utf-8") 231 | 232 | def validate_record(self, *, validate_contents: bool = True) -> None: 233 | """Validate ``RECORD`` of the wheel. 234 | 235 | This method should be called before :py:func:`install ` 236 | if validation is required. 237 | 238 | File names will always be validated against ``RECORD``. 239 | 240 | If ``validate_contents`` is true, sizes and hashes of files 241 | will also be validated against ``RECORD``. 242 | 243 | :param validate_contents: Whether to validate content integrity. 244 | """ 245 | try: 246 | record_lines = self.read_dist_info("RECORD").splitlines() 247 | record_mapping = { 248 | record[0]: record for record in parse_record_file(record_lines) 249 | } 250 | except Exception as exc: 251 | raise _WheelFileValidationError( 252 | [f"Unable to retrieve `RECORD` from {self._zipfile.filename}: {exc!r}"] 253 | ) from exc 254 | 255 | issues: list[str] = [] 256 | 257 | for item in self._zipfile.infolist(): 258 | if item.filename[-1:] == "/": # looks like a directory 259 | continue 260 | 261 | record_args = record_mapping.pop(item.filename, None) 262 | 263 | if self.dist_info_dir == posixpath.commonprefix( 264 | [self.dist_info_dir, item.filename] 265 | ) and item.filename.split("/")[-1] in ("RECORD.p7s", "RECORD.jws"): 266 | # both are for digital signatures, and not mentioned in RECORD 267 | if record_args is not None: 268 | # Incorrectly contained 269 | issues.append( 270 | f"In {self._zipfile.filename}, digital signature file {item.filename} is incorrectly contained in RECORD." 271 | ) 272 | continue 273 | 274 | if record_args is None: 275 | issues.append( 276 | f"In {self._zipfile.filename}, {item.filename} is not mentioned in RECORD" 277 | ) 278 | continue 279 | 280 | record = RecordEntry.from_elements(*record_args) 281 | 282 | if item.filename == f"{self.dist_info_dir}/RECORD": 283 | # Assert that RECORD doesn't have size and hash. 284 | if record.hash_ is not None or record.size is not None: 285 | # Incorrectly contained hash / size 286 | issues.append( 287 | f"In {self._zipfile.filename}, RECORD file incorrectly contains hash / size." 288 | ) 289 | continue 290 | if record.hash_ is None or record.size is None: 291 | # Report empty hash / size 292 | issues.append( 293 | f"In {self._zipfile.filename}, hash / size of {item.filename} is not included in RECORD" 294 | ) 295 | if validate_contents: 296 | with self._zipfile.open(item, "r") as stream: 297 | if not record.validate_stream(cast("BinaryIO", stream)): 298 | issues.append( 299 | f"In {self._zipfile.filename}, hash / size of {item.filename} didn't match RECORD" 300 | ) 301 | 302 | if issues: 303 | raise _WheelFileValidationError(issues) 304 | 305 | def get_contents(self) -> Iterator[WheelContentElement]: 306 | """Sequential access to all contents of the wheel (including dist-info files). 307 | 308 | This implementation requires that every file that is a part of the wheel 309 | archive has a corresponding entry in RECORD. If they are not, an 310 | :any:`AssertionError` will be raised. 311 | """ 312 | # Convert the record file into a useful mapping 313 | record_lines = self.read_dist_info("RECORD").splitlines() 314 | records = parse_record_file(record_lines) 315 | record_mapping = {record[0]: record for record in records} 316 | 317 | for item in self._zipfile.infolist(): 318 | if item.filename[-1:] == "/": # looks like a directory 319 | continue 320 | 321 | # Pop record with empty default, because validation is handled by `validate_record` 322 | record = record_mapping.pop(item.filename, (item.filename, "", "")) 323 | 324 | # Borrowed from: 325 | # https://github.com/pypa/pip/blob/0f21fb92/src/pip/_internal/utils/unpacking.py#L96-L100 326 | mode = item.external_attr >> 16 327 | is_executable = bool(mode and stat.S_ISREG(mode) and mode & 0o111) 328 | 329 | with self._zipfile.open(item) as stream: 330 | stream_casted = cast("BinaryIO", stream) 331 | yield record, stream_casted, is_executable 332 | -------------------------------------------------------------------------------- /tests/test_sources.py: -------------------------------------------------------------------------------- 1 | import json 2 | import posixpath 3 | import zipfile 4 | from base64 import urlsafe_b64encode 5 | from hashlib import sha256 6 | 7 | import pytest 8 | 9 | from installer.exceptions import InstallerError 10 | from installer.records import parse_record_file 11 | from installer.sources import WheelFile, WheelSource 12 | 13 | 14 | class TestWheelSource: 15 | def test_takes_two_arguments(self): 16 | WheelSource("distribution", "version") 17 | WheelSource(distribution="distribution", version="version") 18 | 19 | def test_correctly_computes_properties(self): 20 | source = WheelSource(distribution="distribution", version="version") 21 | 22 | assert source.data_dir == "distribution-version.data" 23 | assert source.dist_info_dir == "distribution-version.dist-info" 24 | 25 | def test_raises_not_implemented_error(self): 26 | source = WheelSource(distribution="distribution", version="version") 27 | 28 | with pytest.raises(NotImplementedError): 29 | _ = source.dist_info_filenames 30 | 31 | with pytest.raises(NotImplementedError): 32 | source.read_dist_info("METADATA") 33 | 34 | with pytest.raises(NotImplementedError): 35 | source.get_contents() 36 | 37 | with pytest.raises(NotImplementedError): 38 | source.validate_record() 39 | 40 | 41 | def replace_file_in_zip(path: str, filename: str, content: "str | None") -> None: 42 | """Helper function for replacing a file in the zip. 43 | 44 | Exists because ZipFile doesn't support remove. 45 | """ 46 | files = {} 47 | # Copy everything except `filename`, and replace it with `content`. 48 | with zipfile.ZipFile(path) as archive: 49 | for file in archive.namelist(): 50 | if file == filename: 51 | if content is None: 52 | continue # Remove the file 53 | files[file] = content.encode() 54 | else: 55 | files[file] = archive.read(file) 56 | # Replace original archive 57 | with zipfile.ZipFile(path, mode="w") as archive: 58 | for name, content in files.items(): 59 | archive.writestr(name, content) 60 | 61 | 62 | class TestWheelFile: 63 | def test_rejects_not_okay_name(self, tmp_path): 64 | # Create an empty zipfile 65 | path = tmp_path / "not_a_valid_name.whl" 66 | with zipfile.ZipFile(str(path), "w"): 67 | pass 68 | 69 | with ( 70 | pytest.raises(ValueError, match=r"Not a valid wheel filename: .+"), 71 | WheelFile.open(str(path)), 72 | ): 73 | pass 74 | 75 | def test_provides_correct_dist_info_filenames(self, fancy_wheel): 76 | with WheelFile.open(fancy_wheel) as source: 77 | assert sorted(source.dist_info_filenames) == [ 78 | "METADATA", 79 | "RECORD", 80 | "WHEEL", 81 | "entry_points.txt", 82 | "top_level.txt", 83 | ] 84 | 85 | def test_correctly_reads_from_dist_info_files(self, fancy_wheel): 86 | files = {} 87 | with zipfile.ZipFile(fancy_wheel) as archive: 88 | for file in archive.namelist(): 89 | if ".dist-info" not in file: 90 | continue 91 | files[posixpath.basename(file)] = archive.read(file).decode("utf-8") 92 | 93 | got_files = {} 94 | with WheelFile.open(fancy_wheel) as source: 95 | for file in files: 96 | got_files[file] = source.read_dist_info(file) 97 | 98 | assert got_files == files 99 | 100 | def test_provides_correct_contents(self, fancy_wheel): 101 | # Know the contents of the wheel 102 | files = {} 103 | with zipfile.ZipFile(fancy_wheel) as archive: 104 | for file in archive.namelist(): 105 | if file[-1:] == "/": 106 | continue 107 | files[file] = archive.read(file) 108 | 109 | expected_record_lines = ( 110 | files["fancy-1.0.0.dist-info/RECORD"].decode("utf-8").splitlines() 111 | ) 112 | expected_records = list(parse_record_file(expected_record_lines)) 113 | 114 | # Check that the object's output is appropriate 115 | got_records = [] 116 | got_files = {} 117 | with WheelFile.open(fancy_wheel) as source: 118 | for record_elements, stream, is_executable in source.get_contents(): 119 | got_records.append(record_elements) 120 | got_files[record_elements[0]] = stream.read() 121 | assert not is_executable 122 | 123 | assert sorted(got_records) == sorted(expected_records) 124 | assert got_files == files 125 | 126 | def test_finds_dist_info(self, fancy_wheel): 127 | denorm = fancy_wheel.rename(fancy_wheel.parent / "Fancy-1.0.0-py3-none-any.whl") 128 | # Python 3.7: rename doesn't return the new name: 129 | denorm = fancy_wheel.parent / "Fancy-1.0.0-py3-none-any.whl" 130 | with WheelFile.open(denorm) as source: 131 | assert source.dist_info_filenames 132 | 133 | def test_requires_dist_info_name_match(self, fancy_wheel): 134 | misnamed = fancy_wheel.rename( 135 | fancy_wheel.parent / "misnamed-1.0.0-py3-none-any.whl" 136 | ) 137 | # Python 3.7: rename doesn't return the new name: 138 | misnamed = fancy_wheel.parent / "misnamed-1.0.0-py3-none-any.whl" 139 | with pytest.raises(InstallerError) as ctx, WheelFile.open(misnamed) as source: 140 | _ = source.dist_info_filenames 141 | 142 | error = ctx.value 143 | assert error.filename == str(misnamed) 144 | assert error.dist_info == "fancy-1.0.0.dist-info" 145 | assert "" in error.reason 146 | assert error.dist_info in str(error) 147 | 148 | def test_enforces_single_dist_info(self, fancy_wheel): 149 | with zipfile.ZipFile(fancy_wheel, "a") as archive: 150 | archive.writestr( 151 | "name-1.0.0.dist-info/random.txt", 152 | b"This is a random file.", 153 | ) 154 | 155 | with ( 156 | pytest.raises(InstallerError) as ctx, 157 | WheelFile.open(fancy_wheel) as source, 158 | ): 159 | _ = source.dist_info_filenames 160 | 161 | error = ctx.value 162 | assert error.filename == str(fancy_wheel) 163 | assert error.dist_info == str(["fancy-1.0.0.dist-info", "name-1.0.0.dist-info"]) 164 | assert "exactly one .dist-info" in error.reason 165 | assert error.dist_info in str(error) 166 | 167 | def test_rejects_no_record_on_validate(self, fancy_wheel): 168 | # Remove RECORD 169 | replace_file_in_zip( 170 | fancy_wheel, 171 | filename="fancy-1.0.0.dist-info/RECORD", 172 | content=None, 173 | ) 174 | with ( 175 | WheelFile.open(fancy_wheel) as source, 176 | pytest.raises( 177 | WheelFile.validation_error, match="Unable to retrieve `RECORD`" 178 | ), 179 | ): 180 | source.validate_record(validate_contents=False) 181 | 182 | def test_rejects_invalid_record_entry(self, fancy_wheel): 183 | with WheelFile.open(fancy_wheel) as source: 184 | record_file_contents = source.read_dist_info("RECORD") 185 | 186 | replace_file_in_zip( 187 | fancy_wheel, 188 | filename="fancy-1.0.0.dist-info/RECORD", 189 | content="\n".join( 190 | line.replace("sha256=", "") for line in record_file_contents 191 | ), 192 | ) 193 | with ( 194 | WheelFile.open(fancy_wheel) as source, 195 | pytest.raises( 196 | WheelFile.validation_error, 197 | match="Unable to retrieve `RECORD`", 198 | ), 199 | ): 200 | source.validate_record() 201 | 202 | def test_rejects_record_missing_file_on_validate(self, fancy_wheel): 203 | with WheelFile.open(fancy_wheel) as source: 204 | record_file_contents = source.read_dist_info("RECORD") 205 | 206 | # Remove the first two entries from the RECORD file 207 | new_record_file_contents = "\n".join(record_file_contents.split("\n")[2:]) 208 | replace_file_in_zip( 209 | fancy_wheel, 210 | filename="fancy-1.0.0.dist-info/RECORD", 211 | content=new_record_file_contents, 212 | ) 213 | with ( 214 | WheelFile.open(fancy_wheel) as source, 215 | pytest.raises(WheelFile.validation_error, match="not mentioned in RECORD"), 216 | ): 217 | source.validate_record(validate_contents=False) 218 | 219 | def test_rejects_record_missing_hash(self, fancy_wheel): 220 | with WheelFile.open(fancy_wheel) as source: 221 | record_file_contents = source.read_dist_info("RECORD") 222 | 223 | new_record_file_contents = "\n".join( 224 | line.split(",")[0] + ",," # file name with empty size and hash 225 | for line in record_file_contents.split("\n") 226 | ) 227 | replace_file_in_zip( 228 | fancy_wheel, 229 | filename="fancy-1.0.0.dist-info/RECORD", 230 | content=new_record_file_contents, 231 | ) 232 | with ( 233 | WheelFile.open(fancy_wheel) as source, 234 | pytest.raises( 235 | WheelFile.validation_error, 236 | match=r"hash / size of (.+) is not included in RECORD", 237 | ), 238 | ): 239 | source.validate_record(validate_contents=False) 240 | 241 | def test_accept_wheel_with_signature_file(self, fancy_wheel): 242 | with WheelFile.open(fancy_wheel) as source: 243 | record_file_contents = source.read_dist_info("RECORD") 244 | hash_b64_nopad = ( 245 | urlsafe_b64encode(sha256(record_file_contents.encode()).digest()) 246 | .decode("utf-8") 247 | .rstrip("=") 248 | ) 249 | jws_content = json.dumps({"hash": f"sha256={hash_b64_nopad}"}) 250 | with zipfile.ZipFile(fancy_wheel, "a") as archive: 251 | archive.writestr("fancy-1.0.0.dist-info/RECORD.jws", jws_content) 252 | with WheelFile.open(fancy_wheel) as source: 253 | source.validate_record() 254 | 255 | def test_reject_signature_file_in_record(self, fancy_wheel): 256 | with WheelFile.open(fancy_wheel) as source: 257 | record_file_contents = source.read_dist_info("RECORD") 258 | record_hash_nopad = ( 259 | urlsafe_b64encode(sha256(record_file_contents.encode()).digest()) 260 | .decode("utf-8") 261 | .rstrip("=") 262 | ) 263 | jws_content = json.dumps({"hash": f"sha256={record_hash_nopad}"}) 264 | with zipfile.ZipFile(fancy_wheel, "a") as archive: 265 | archive.writestr("fancy-1.0.0.dist-info/RECORD.jws", jws_content) 266 | 267 | # Add signature file to RECORD 268 | jws_content = jws_content.encode() 269 | jws_hash_nopad = ( 270 | urlsafe_b64encode(sha256(jws_content).digest()).decode("utf-8").rstrip("=") 271 | ) 272 | replace_file_in_zip( 273 | fancy_wheel, 274 | filename="fancy-1.0.0.dist-info/RECORD", 275 | content=record_file_contents.rstrip("\n") 276 | + f"\nfancy-1.0.0.dist-info/RECORD.jws,sha256={jws_hash_nopad},{len(jws_content)}\n", 277 | ) 278 | with ( 279 | WheelFile.open(fancy_wheel) as source, 280 | pytest.raises( 281 | WheelFile.validation_error, 282 | match=r"digital signature file (.+) is incorrectly contained in RECORD.", 283 | ), 284 | ): 285 | source.validate_record(validate_contents=False) 286 | 287 | def test_rejects_record_contain_self_hash(self, fancy_wheel): 288 | with WheelFile.open(fancy_wheel) as source: 289 | record_file_contents = source.read_dist_info("RECORD") 290 | 291 | new_record_file_lines = [] 292 | for line in record_file_contents.split("\n"): 293 | if not line: 294 | continue 295 | filename, hash_, size = line.split(",") 296 | if filename.split("/")[-1] == "RECORD": 297 | hash_ = "sha256=pREiHcl39jRySUXMCOrwmSsnOay8FB7fOJP5mZQ3D3A" 298 | size = str(len(record_file_contents)) 299 | new_record_file_lines.append(f"{filename},{hash_},{size}") 300 | 301 | replace_file_in_zip( 302 | fancy_wheel, 303 | filename="fancy-1.0.0.dist-info/RECORD", 304 | content="\n".join(new_record_file_lines), 305 | ) 306 | with ( 307 | WheelFile.open(fancy_wheel) as source, 308 | pytest.raises( 309 | WheelFile.validation_error, 310 | match=r"RECORD file incorrectly contains hash / size.", 311 | ), 312 | ): 313 | source.validate_record(validate_contents=False) 314 | 315 | def test_rejects_record_validation_failed(self, fancy_wheel): 316 | with WheelFile.open(fancy_wheel) as source: 317 | record_file_contents = source.read_dist_info("RECORD") 318 | 319 | new_record_file_lines = [] 320 | for line in record_file_contents.split("\n"): 321 | if not line: 322 | continue 323 | filename, hash_, size = line.split(",") 324 | if filename.split("/")[-1] != "RECORD": 325 | hash_ = "sha256=pREiHcl39jRySUXMCOrwmSsnOay8FB7fOJP5mZQ3D3A" 326 | new_record_file_lines.append(f"{filename},{hash_},{size}") 327 | 328 | replace_file_in_zip( 329 | fancy_wheel, 330 | filename="fancy-1.0.0.dist-info/RECORD", 331 | content="\n".join(new_record_file_lines), 332 | ) 333 | with ( 334 | WheelFile.open(fancy_wheel) as source, 335 | pytest.raises( 336 | WheelFile.validation_error, 337 | match=r"hash / size of (.+) didn't match RECORD", 338 | ), 339 | ): 340 | source.validate_record() 341 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import textwrap 3 | from io import BytesIO 4 | from unittest import mock 5 | 6 | import pytest 7 | 8 | from installer import install 9 | from installer.exceptions import InvalidWheelSource 10 | from installer.records import RecordEntry 11 | from installer.sources import WheelSource 12 | 13 | 14 | # -------------------------------------------------------------------------------------- 15 | # Helpers 16 | # -------------------------------------------------------------------------------------- 17 | def hash_and_size(data): 18 | return hashlib.sha256(data).hexdigest(), len(data) 19 | 20 | 21 | @pytest.fixture 22 | def mock_destination(): 23 | retval = mock.Mock() 24 | 25 | # A hacky approach to making sure we got the right objects going in. 26 | def custom_write_file(scheme, path, stream, is_executable): 27 | assert isinstance(stream, BytesIO) 28 | return (path, scheme, 0) 29 | 30 | def custom_write_script(name, module, attr, section): 31 | return (name, module, attr, section) 32 | 33 | retval.write_file.side_effect = custom_write_file 34 | retval.write_script.side_effect = custom_write_script 35 | 36 | return retval 37 | 38 | 39 | class FakeWheelSource(WheelSource): 40 | def __init__(self, *, distribution, version, regular_files, dist_info_files): 41 | super().__init__(distribution, version) 42 | 43 | self.dist_info_files = { 44 | file: textwrap.dedent(content.decode("utf-8")) 45 | for file, content in dist_info_files.items() 46 | } 47 | self.regular_files = { 48 | file: textwrap.dedent(content.decode("utf-8")).encode("utf-8") 49 | for file, content in regular_files.items() 50 | } 51 | 52 | # Compute RECORD file. 53 | _records = [record for record, _, _ in self.get_contents()] 54 | self.dist_info_files["RECORD"] = "\n".join( 55 | sorted( 56 | ",".join([file, "sha256=" + hash_, str(size)]) 57 | for file, hash_, size in _records 58 | ) 59 | ) 60 | 61 | @property 62 | def dist_info_filenames(self): 63 | return list(self.dist_info_files) 64 | 65 | def read_dist_info(self, filename): 66 | return self.dist_info_files[filename] 67 | 68 | def validate_record(self) -> None: 69 | # Skip validation since the logic is different. 70 | return 71 | 72 | def get_contents(self): 73 | # Sort for deterministic behaviour for Python versions that do not preserve 74 | # insertion order for dictionaries. 75 | for file, content in sorted(self.regular_files.items()): 76 | hashed, size = hash_and_size(content) 77 | record = (file, f"sha256={hashed}", str(size)) 78 | with BytesIO(content) as stream: 79 | yield record, stream, False 80 | 81 | # Sort for deterministic behaviour for Python versions that do not preserve 82 | # insertion order for dictionaries. 83 | for file, text in sorted(self.dist_info_files.items()): 84 | content = text.encode("utf-8") 85 | hashed, size = hash_and_size(content) 86 | record = ( 87 | self.dist_info_dir + "/" + file, 88 | f"sha256={hashed}", 89 | str(size), 90 | ) 91 | with BytesIO(content) as stream: 92 | yield record, stream, False 93 | 94 | 95 | # -------------------------------------------------------------------------------------- 96 | # Actual Tests 97 | # -------------------------------------------------------------------------------------- 98 | class TestInstall: 99 | def test_calls_destination_correctly(self, mock_destination): 100 | # Create a fake wheel 101 | source = FakeWheelSource( 102 | distribution="fancy", 103 | version="1.0.0", 104 | regular_files={ 105 | "fancy/__init__.py": b"""\ 106 | def main(): 107 | print("I'm a fancy package") 108 | """, 109 | "fancy/__main__.py": b"""\ 110 | if __name__ == "__main__": 111 | from . import main 112 | main() 113 | """, 114 | }, 115 | dist_info_files={ 116 | "top_level.txt": b"""\ 117 | fancy 118 | """, 119 | "entry_points.txt": b"""\ 120 | [console_scripts] 121 | fancy = fancy:main 122 | 123 | [gui_scripts] 124 | fancy-gui = fancy:main 125 | """, 126 | "WHEEL": b"""\ 127 | Wheel-Version: 1.0 128 | Generator: magic (1.0.0) 129 | Root-Is-Purelib: true 130 | Tag: py3-none-any 131 | """, 132 | "METADATA": b"""\ 133 | Metadata-Version: 2.1 134 | Name: fancy 135 | Version: 1.0.0 136 | Summary: A fancy package 137 | Author: Agendaless Consulting 138 | Author-email: nobody@example.com 139 | License: MIT 140 | Keywords: fancy amazing 141 | Platform: UNKNOWN 142 | Classifier: Intended Audience :: Developers 143 | """, 144 | }, 145 | ) 146 | 147 | install( 148 | source=source, 149 | destination=mock_destination, 150 | additional_metadata={ 151 | "fun_file.txt": b"this should be in dist-info!", 152 | }, 153 | ) 154 | 155 | mock_destination.assert_has_calls( 156 | [ 157 | mock.call.write_script( 158 | name="fancy", 159 | module="fancy", 160 | attr="main", 161 | section="console", 162 | ), 163 | mock.call.write_script( 164 | name="fancy-gui", 165 | module="fancy", 166 | attr="main", 167 | section="gui", 168 | ), 169 | mock.call.write_file( 170 | scheme="purelib", 171 | path="fancy/__init__.py", 172 | stream=mock.ANY, 173 | is_executable=False, 174 | ), 175 | mock.call.write_file( 176 | scheme="purelib", 177 | path="fancy/__main__.py", 178 | stream=mock.ANY, 179 | is_executable=False, 180 | ), 181 | mock.call.write_file( 182 | scheme="purelib", 183 | path="fancy-1.0.0.dist-info/METADATA", 184 | stream=mock.ANY, 185 | is_executable=False, 186 | ), 187 | mock.call.write_file( 188 | scheme="purelib", 189 | path="fancy-1.0.0.dist-info/WHEEL", 190 | stream=mock.ANY, 191 | is_executable=False, 192 | ), 193 | mock.call.write_file( 194 | scheme="purelib", 195 | path="fancy-1.0.0.dist-info/entry_points.txt", 196 | stream=mock.ANY, 197 | is_executable=False, 198 | ), 199 | mock.call.write_file( 200 | scheme="purelib", 201 | path="fancy-1.0.0.dist-info/top_level.txt", 202 | stream=mock.ANY, 203 | is_executable=False, 204 | ), 205 | mock.call.write_file( 206 | scheme="purelib", 207 | path="fancy-1.0.0.dist-info/fun_file.txt", 208 | stream=mock.ANY, 209 | is_executable=False, 210 | ), 211 | mock.call.finalize_installation( 212 | scheme="purelib", 213 | record_file_path="fancy-1.0.0.dist-info/RECORD", 214 | records=[ 215 | ("scripts", ("fancy", "fancy", "main", "console")), 216 | ("scripts", ("fancy-gui", "fancy", "main", "gui")), 217 | ("purelib", ("fancy/__init__.py", "purelib", 0)), 218 | ("purelib", ("fancy/__main__.py", "purelib", 0)), 219 | ("purelib", ("fancy-1.0.0.dist-info/METADATA", "purelib", 0)), 220 | ("purelib", ("fancy-1.0.0.dist-info/WHEEL", "purelib", 0)), 221 | ( 222 | "purelib", 223 | ("fancy-1.0.0.dist-info/entry_points.txt", "purelib", 0), 224 | ), 225 | ( 226 | "purelib", 227 | ("fancy-1.0.0.dist-info/top_level.txt", "purelib", 0), 228 | ), 229 | ( 230 | "purelib", 231 | ("fancy-1.0.0.dist-info/fun_file.txt", "purelib", 0), 232 | ), 233 | ( 234 | "purelib", 235 | RecordEntry("fancy-1.0.0.dist-info/RECORD", None, None), 236 | ), 237 | ], 238 | ), 239 | ] 240 | ) 241 | 242 | def test_no_entrypoints_is_ok(self, mock_destination): 243 | # Create a fake wheel 244 | source = FakeWheelSource( 245 | distribution="fancy", 246 | version="1.0.0", 247 | regular_files={ 248 | "fancy/__init__.py": b"""\ 249 | def main(): 250 | print("I'm a fancy package") 251 | """, 252 | "fancy/__main__.py": b"""\ 253 | if __name__ == "__main__": 254 | from . import main 255 | main() 256 | """, 257 | }, 258 | dist_info_files={ 259 | "top_level.txt": b"""\ 260 | fancy 261 | """, 262 | "WHEEL": b"""\ 263 | Wheel-Version: 1.0 264 | Generator: magic (1.0.0) 265 | Root-Is-Purelib: true 266 | Tag: py3-none-any 267 | """, 268 | "METADATA": b"""\ 269 | Metadata-Version: 2.1 270 | Name: fancy 271 | Version: 1.0.0 272 | Summary: A fancy package 273 | Author: Agendaless Consulting 274 | Author-email: nobody@example.com 275 | License: MIT 276 | Keywords: fancy amazing 277 | Platform: UNKNOWN 278 | Classifier: Intended Audience :: Developers 279 | """, 280 | }, 281 | ) 282 | 283 | install( 284 | source=source, 285 | destination=mock_destination, 286 | additional_metadata={ 287 | "fun_file.txt": b"this should be in dist-info!", 288 | }, 289 | ) 290 | 291 | mock_destination.assert_has_calls( 292 | [ 293 | mock.call.write_file( 294 | scheme="purelib", 295 | path="fancy/__init__.py", 296 | stream=mock.ANY, 297 | is_executable=False, 298 | ), 299 | mock.call.write_file( 300 | scheme="purelib", 301 | path="fancy/__main__.py", 302 | stream=mock.ANY, 303 | is_executable=False, 304 | ), 305 | mock.call.write_file( 306 | scheme="purelib", 307 | path="fancy-1.0.0.dist-info/METADATA", 308 | stream=mock.ANY, 309 | is_executable=False, 310 | ), 311 | mock.call.write_file( 312 | scheme="purelib", 313 | path="fancy-1.0.0.dist-info/WHEEL", 314 | stream=mock.ANY, 315 | is_executable=False, 316 | ), 317 | mock.call.write_file( 318 | scheme="purelib", 319 | path="fancy-1.0.0.dist-info/top_level.txt", 320 | stream=mock.ANY, 321 | is_executable=False, 322 | ), 323 | mock.call.write_file( 324 | scheme="purelib", 325 | path="fancy-1.0.0.dist-info/fun_file.txt", 326 | stream=mock.ANY, 327 | is_executable=False, 328 | ), 329 | mock.call.finalize_installation( 330 | scheme="purelib", 331 | record_file_path="fancy-1.0.0.dist-info/RECORD", 332 | records=[ 333 | ("purelib", ("fancy/__init__.py", "purelib", 0)), 334 | ("purelib", ("fancy/__main__.py", "purelib", 0)), 335 | ("purelib", ("fancy-1.0.0.dist-info/METADATA", "purelib", 0)), 336 | ("purelib", ("fancy-1.0.0.dist-info/WHEEL", "purelib", 0)), 337 | ( 338 | "purelib", 339 | ("fancy-1.0.0.dist-info/top_level.txt", "purelib", 0), 340 | ), 341 | ( 342 | "purelib", 343 | ("fancy-1.0.0.dist-info/fun_file.txt", "purelib", 0), 344 | ), 345 | ( 346 | "purelib", 347 | RecordEntry("fancy-1.0.0.dist-info/RECORD", None, None), 348 | ), 349 | ], 350 | ), 351 | ] 352 | ) 353 | 354 | def test_handles_platlib(self, mock_destination): 355 | # Create a fake wheel 356 | source = FakeWheelSource( 357 | distribution="fancy", 358 | version="1.0.0", 359 | regular_files={ 360 | "fancy/__init__.py": b"""\ 361 | def main(): 362 | print("I'm a fancy package") 363 | """, 364 | "fancy/__main__.py": b"""\ 365 | if __name__ == "__main__": 366 | from . import main 367 | main() 368 | """, 369 | }, 370 | dist_info_files={ 371 | "top_level.txt": b"""\ 372 | fancy 373 | """, 374 | "entry_points.txt": b"""\ 375 | [console_scripts] 376 | fancy = fancy:main 377 | 378 | [gui_scripts] 379 | fancy-gui = fancy:main 380 | """, 381 | "WHEEL": b"""\ 382 | Wheel-Version: 1.0 383 | Generator: magic (1.0.0) 384 | Root-Is-Purelib: false 385 | Tag: py3-none-any 386 | """, 387 | "METADATA": b"""\ 388 | Metadata-Version: 2.1 389 | Name: fancy 390 | Version: 1.0.0 391 | Summary: A fancy package 392 | Author: Agendaless Consulting 393 | Author-email: nobody@example.com 394 | License: MIT 395 | Keywords: fancy amazing 396 | Platform: UNKNOWN 397 | Classifier: Intended Audience :: Developers 398 | """, 399 | }, 400 | ) 401 | 402 | install( 403 | source=source, 404 | destination=mock_destination, 405 | additional_metadata={ 406 | "fun_file.txt": b"this should be in dist-info!", 407 | }, 408 | ) 409 | 410 | mock_destination.assert_has_calls( 411 | [ 412 | mock.call.write_script( 413 | name="fancy", 414 | module="fancy", 415 | attr="main", 416 | section="console", 417 | ), 418 | mock.call.write_script( 419 | name="fancy-gui", 420 | module="fancy", 421 | attr="main", 422 | section="gui", 423 | ), 424 | mock.call.write_file( 425 | scheme="platlib", 426 | path="fancy/__init__.py", 427 | stream=mock.ANY, 428 | is_executable=False, 429 | ), 430 | mock.call.write_file( 431 | scheme="platlib", 432 | path="fancy/__main__.py", 433 | stream=mock.ANY, 434 | is_executable=False, 435 | ), 436 | mock.call.write_file( 437 | scheme="platlib", 438 | path="fancy-1.0.0.dist-info/METADATA", 439 | stream=mock.ANY, 440 | is_executable=False, 441 | ), 442 | mock.call.write_file( 443 | scheme="platlib", 444 | path="fancy-1.0.0.dist-info/WHEEL", 445 | stream=mock.ANY, 446 | is_executable=False, 447 | ), 448 | mock.call.write_file( 449 | scheme="platlib", 450 | path="fancy-1.0.0.dist-info/entry_points.txt", 451 | stream=mock.ANY, 452 | is_executable=False, 453 | ), 454 | mock.call.write_file( 455 | scheme="platlib", 456 | path="fancy-1.0.0.dist-info/top_level.txt", 457 | stream=mock.ANY, 458 | is_executable=False, 459 | ), 460 | mock.call.write_file( 461 | scheme="platlib", 462 | path="fancy-1.0.0.dist-info/fun_file.txt", 463 | stream=mock.ANY, 464 | is_executable=False, 465 | ), 466 | mock.call.finalize_installation( 467 | scheme="platlib", 468 | record_file_path="fancy-1.0.0.dist-info/RECORD", 469 | records=[ 470 | ("scripts", ("fancy", "fancy", "main", "console")), 471 | ("scripts", ("fancy-gui", "fancy", "main", "gui")), 472 | ("platlib", ("fancy/__init__.py", "platlib", 0)), 473 | ("platlib", ("fancy/__main__.py", "platlib", 0)), 474 | ("platlib", ("fancy-1.0.0.dist-info/METADATA", "platlib", 0)), 475 | ("platlib", ("fancy-1.0.0.dist-info/WHEEL", "platlib", 0)), 476 | ( 477 | "platlib", 478 | ("fancy-1.0.0.dist-info/entry_points.txt", "platlib", 0), 479 | ), 480 | ( 481 | "platlib", 482 | ("fancy-1.0.0.dist-info/top_level.txt", "platlib", 0), 483 | ), 484 | ( 485 | "platlib", 486 | ("fancy-1.0.0.dist-info/fun_file.txt", "platlib", 0), 487 | ), 488 | ( 489 | "platlib", 490 | RecordEntry("fancy-1.0.0.dist-info/RECORD", None, None), 491 | ), 492 | ], 493 | ), 494 | ] 495 | ) 496 | 497 | def test_accepts_newer_minor_wheel_versions(self, mock_destination): 498 | # Create a fake wheel 499 | source = FakeWheelSource( 500 | distribution="fancy", 501 | version="1.0.0", 502 | regular_files={ 503 | "fancy/__init__.py": b"""\ 504 | def main(): 505 | print("I'm a fancy package") 506 | """, 507 | "fancy/__main__.py": b"""\ 508 | if __name__ == "__main__": 509 | from . import main 510 | main() 511 | """, 512 | }, 513 | dist_info_files={ 514 | "top_level.txt": b"""\ 515 | fancy 516 | """, 517 | "entry_points.txt": b"""\ 518 | [console_scripts] 519 | fancy = fancy:main 520 | 521 | [gui_scripts] 522 | fancy-gui = fancy:main 523 | """, 524 | "WHEEL": b"""\ 525 | Wheel-Version: 1.1 526 | Generator: magic (1.0.0) 527 | Root-Is-Purelib: true 528 | Tag: py3-none-any 529 | """, 530 | "METADATA": b"""\ 531 | Metadata-Version: 2.1 532 | Name: fancy 533 | Version: 1.0.0 534 | Summary: A fancy package 535 | Author: Agendaless Consulting 536 | Author-email: nobody@example.com 537 | License: MIT 538 | Keywords: fancy amazing 539 | Platform: UNKNOWN 540 | Classifier: Intended Audience :: Developers 541 | """, 542 | }, 543 | ) 544 | 545 | install( 546 | source=source, 547 | destination=mock_destination, 548 | additional_metadata={ 549 | "fun_file.txt": b"this should be in dist-info!", 550 | }, 551 | ) 552 | 553 | # no assertions necessary, since we want to make sure this test didn't 554 | # raises errors. 555 | assert True 556 | 557 | def test_rejects_newer_major_wheel_versions(self, mock_destination): 558 | # Create a fake wheel 559 | source = FakeWheelSource( 560 | distribution="fancy", 561 | version="1.0.0", 562 | regular_files={ 563 | "fancy/__init__.py": b"""\ 564 | def main(): 565 | print("I'm a fancy package") 566 | """, 567 | "fancy/__main__.py": b"""\ 568 | if __name__ == "__main__": 569 | from . import main 570 | main() 571 | """, 572 | }, 573 | dist_info_files={ 574 | "top_level.txt": b"""\ 575 | fancy 576 | """, 577 | "entry_points.txt": b"""\ 578 | [console_scripts] 579 | fancy = fancy:main 580 | 581 | [gui_scripts] 582 | fancy-gui = fancy:main 583 | """, 584 | "WHEEL": b"""\ 585 | Wheel-Version: 2.0 586 | Generator: magic (1.0.0) 587 | Root-Is-Purelib: true 588 | Tag: py3-none-any 589 | """, 590 | "METADATA": b"""\ 591 | Metadata-Version: 2.1 592 | Name: fancy 593 | Version: 1.0.0 594 | Summary: A fancy package 595 | Author: Agendaless Consulting 596 | Author-email: nobody@example.com 597 | License: MIT 598 | Keywords: fancy amazing 599 | Platform: UNKNOWN 600 | Classifier: Intended Audience :: Developers 601 | """, 602 | }, 603 | ) 604 | 605 | with pytest.raises(InvalidWheelSource) as ctx: 606 | install( 607 | source=source, 608 | destination=mock_destination, 609 | additional_metadata={ 610 | "fun_file.txt": b"this should be in dist-info!", 611 | }, 612 | ) 613 | 614 | assert "Incompatible Wheel-Version" in str(ctx.value) 615 | 616 | def test_handles_data_properly(self, mock_destination): 617 | # Create a fake wheel 618 | source = FakeWheelSource( 619 | distribution="fancy", 620 | version="1.0.0", 621 | regular_files={ 622 | "fancy/__init__.py": b"""\ 623 | # put me in purelib 624 | """, 625 | "fancy-1.0.0.data/purelib/fancy/purelib.py": b"""\ 626 | # put me in purelib 627 | """, 628 | "fancy-1.0.0.data/platlib/fancy/platlib.py": b"""\ 629 | # put me in platlib 630 | """, 631 | "fancy-1.0.0.data/scripts/fancy/scripts.py": b"""\ 632 | # put me in scripts 633 | """, 634 | "fancy-1.0.0.data/headers/fancy/headers.py": b"""\ 635 | # put me in headers 636 | """, 637 | "fancy-1.0.0.data/data/fancy/data.py": b"""\ 638 | # put me in data 639 | """, 640 | }, 641 | dist_info_files={ 642 | "top_level.txt": b"""\ 643 | fancy 644 | """, 645 | "entry_points.txt": b"""\ 646 | [console_scripts] 647 | fancy = fancy:main 648 | 649 | [gui_scripts] 650 | fancy-gui = fancy:main 651 | """, 652 | "WHEEL": b"""\ 653 | Wheel-Version: 1.0 654 | Generator: magic (1.0.0) 655 | Root-Is-Purelib: true 656 | Tag: py3-none-any 657 | """, 658 | "METADATA": b"""\ 659 | Metadata-Version: 2.1 660 | Name: fancy 661 | Version: 1.0.0 662 | Summary: A fancy package 663 | Author: Agendaless Consulting 664 | Author-email: nobody@example.com 665 | License: MIT 666 | Keywords: fancy amazing 667 | Platform: UNKNOWN 668 | Classifier: Intended Audience :: Developers 669 | """, 670 | }, 671 | ) 672 | 673 | install( 674 | source=source, 675 | destination=mock_destination, 676 | additional_metadata={}, 677 | ) 678 | 679 | mock_destination.assert_has_calls( 680 | [ 681 | mock.call.write_script( 682 | name="fancy", 683 | module="fancy", 684 | attr="main", 685 | section="console", 686 | ), 687 | mock.call.write_script( 688 | name="fancy-gui", 689 | module="fancy", 690 | attr="main", 691 | section="gui", 692 | ), 693 | mock.call.write_file( 694 | scheme="data", 695 | path="fancy/data.py", 696 | stream=mock.ANY, 697 | is_executable=False, 698 | ), 699 | mock.call.write_file( 700 | scheme="headers", 701 | path="fancy/headers.py", 702 | stream=mock.ANY, 703 | is_executable=False, 704 | ), 705 | mock.call.write_file( 706 | scheme="platlib", 707 | path="fancy/platlib.py", 708 | stream=mock.ANY, 709 | is_executable=False, 710 | ), 711 | mock.call.write_file( 712 | scheme="purelib", 713 | path="fancy/purelib.py", 714 | stream=mock.ANY, 715 | is_executable=False, 716 | ), 717 | mock.call.write_file( 718 | scheme="scripts", 719 | path="fancy/scripts.py", 720 | stream=mock.ANY, 721 | is_executable=False, 722 | ), 723 | mock.call.write_file( 724 | scheme="purelib", 725 | path="fancy/__init__.py", 726 | stream=mock.ANY, 727 | is_executable=False, 728 | ), 729 | mock.call.write_file( 730 | scheme="purelib", 731 | path="fancy-1.0.0.dist-info/METADATA", 732 | stream=mock.ANY, 733 | is_executable=False, 734 | ), 735 | mock.call.write_file( 736 | scheme="purelib", 737 | path="fancy-1.0.0.dist-info/WHEEL", 738 | stream=mock.ANY, 739 | is_executable=False, 740 | ), 741 | mock.call.write_file( 742 | scheme="purelib", 743 | path="fancy-1.0.0.dist-info/entry_points.txt", 744 | stream=mock.ANY, 745 | is_executable=False, 746 | ), 747 | mock.call.write_file( 748 | scheme="purelib", 749 | path="fancy-1.0.0.dist-info/top_level.txt", 750 | stream=mock.ANY, 751 | is_executable=False, 752 | ), 753 | mock.call.finalize_installation( 754 | scheme="purelib", 755 | record_file_path="fancy-1.0.0.dist-info/RECORD", 756 | records=[ 757 | ("scripts", ("fancy", "fancy", "main", "console")), 758 | ("scripts", ("fancy-gui", "fancy", "main", "gui")), 759 | ("data", ("fancy/data.py", "data", 0)), 760 | ("headers", ("fancy/headers.py", "headers", 0)), 761 | ("platlib", ("fancy/platlib.py", "platlib", 0)), 762 | ("purelib", ("fancy/purelib.py", "purelib", 0)), 763 | ("scripts", ("fancy/scripts.py", "scripts", 0)), 764 | ("purelib", ("fancy/__init__.py", "purelib", 0)), 765 | ("purelib", ("fancy-1.0.0.dist-info/METADATA", "purelib", 0)), 766 | ("purelib", ("fancy-1.0.0.dist-info/WHEEL", "purelib", 0)), 767 | ( 768 | "purelib", 769 | ("fancy-1.0.0.dist-info/entry_points.txt", "purelib", 0), 770 | ), 771 | ( 772 | "purelib", 773 | ("fancy-1.0.0.dist-info/top_level.txt", "purelib", 0), 774 | ), 775 | ( 776 | "purelib", 777 | RecordEntry("fancy-1.0.0.dist-info/RECORD", None, None), 778 | ), 779 | ], 780 | ), 781 | ] 782 | ) 783 | 784 | def test_errors_out_when_given_invalid_scheme_in_data(self, mock_destination): 785 | # Create a fake wheel 786 | source = FakeWheelSource( 787 | distribution="fancy", 788 | version="1.0.0", 789 | regular_files={ 790 | "fancy/__init__.py": b"""\ 791 | # put me in purelib 792 | """, 793 | "fancy-1.0.0.data/purelib/fancy/purelib.py": b"""\ 794 | # put me in purelib 795 | """, 796 | "fancy-1.0.0.data/invalid/fancy/invalid.py": b"""\ 797 | # i am invalid 798 | """, 799 | }, 800 | dist_info_files={ 801 | "top_level.txt": b"""\ 802 | fancy 803 | """, 804 | "entry_points.txt": b"""\ 805 | [console_scripts] 806 | fancy = fancy:main 807 | 808 | [gui_scripts] 809 | fancy-gui = fancy:main 810 | """, 811 | "WHEEL": b"""\ 812 | Wheel-Version: 1.0 813 | Generator: magic (1.0.0) 814 | Root-Is-Purelib: true 815 | Tag: py3-none-any 816 | """, 817 | "METADATA": b"""\ 818 | Metadata-Version: 2.1 819 | Name: fancy 820 | Version: 1.0.0 821 | Summary: A fancy package 822 | Author: Agendaless Consulting 823 | Author-email: nobody@example.com 824 | License: MIT 825 | Keywords: fancy amazing 826 | Platform: UNKNOWN 827 | Classifier: Intended Audience :: Developers 828 | """, 829 | }, 830 | ) 831 | 832 | with pytest.raises(InvalidWheelSource) as ctx: 833 | install( 834 | source=source, 835 | destination=mock_destination, 836 | additional_metadata={}, 837 | ) 838 | 839 | assert "fancy-1.0.0.data/invalid/fancy/invalid.py" in str(ctx.value) 840 | 841 | def test_ensure_non_executable_for_additional_metadata(self, mock_destination): 842 | # Create a fake wheel 843 | source = FakeWheelSource( 844 | distribution="fancy", 845 | version="1.0.0", 846 | regular_files={ 847 | "fancy/__init__.py": b"""\ 848 | # put me in purelib 849 | """, 850 | }, 851 | dist_info_files={ 852 | "top_level.txt": b"""\ 853 | fancy 854 | """, 855 | "WHEEL": b"""\ 856 | Wheel-Version: 1.0 857 | Generator: magic (1.0.0) 858 | Root-Is-Purelib: true 859 | Tag: py3-none-any 860 | """, 861 | "METADATA": b"""\ 862 | Metadata-Version: 2.1 863 | Name: fancy 864 | Version: 1.0.0 865 | Summary: A fancy package 866 | Author: Agendaless Consulting 867 | Author-email: nobody@example.com 868 | License: MIT 869 | Keywords: fancy amazing 870 | Platform: UNKNOWN 871 | Classifier: Intended Audience :: Developers 872 | """, 873 | }, 874 | ) 875 | all_contents = list(source.get_contents()) 876 | source.get_contents = lambda: ( 877 | (*contents, True) for (*contents, _) in all_contents 878 | ) 879 | install( 880 | source=source, 881 | destination=mock_destination, 882 | additional_metadata={ 883 | "fun_file.txt": b"this should be in dist-info!", 884 | }, 885 | ) 886 | 887 | mock_destination.assert_has_calls( 888 | [ 889 | mock.call.write_file( 890 | scheme="purelib", 891 | path="fancy/__init__.py", 892 | stream=mock.ANY, 893 | is_executable=True, 894 | ), 895 | mock.call.write_file( 896 | scheme="purelib", 897 | path="fancy-1.0.0.dist-info/METADATA", 898 | stream=mock.ANY, 899 | is_executable=True, 900 | ), 901 | mock.call.write_file( 902 | scheme="purelib", 903 | path="fancy-1.0.0.dist-info/fun_file.txt", 904 | stream=mock.ANY, 905 | is_executable=False, 906 | ), 907 | ], 908 | any_order=True, 909 | ) 910 | --------------------------------------------------------------------------------