├── .github ├── FUNDING.yml ├── dependabot.yml ├── release.yml └── workflows │ ├── check.yaml │ └── release.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yml ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── docs ├── api.rst ├── conf.py └── index.rst ├── pyproject.toml ├── src └── pyproject_api │ ├── __init__.py │ ├── __main__.py │ ├── _backend.py │ ├── _backend.pyi │ ├── _frontend.py │ ├── _util.py │ ├── _via_fresh_subprocess.py │ └── py.typed ├── tests ├── _build_sdist.py ├── demo_pkg_inline │ ├── build.py │ └── pyproject.toml ├── test_backend.py ├── test_frontend.py ├── test_frontend_setuptools.py ├── test_main.py ├── test_util.py └── test_version.py └── tox.ini /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | tidelift: "pypi/pyproject-api" 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | authors: 4 | - dependabot 5 | - pre-commit-ci 6 | -------------------------------------------------------------------------------- /.github/workflows/check.yaml: -------------------------------------------------------------------------------- 1 | name: check 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: ["main"] 6 | tags-ignore: ["**"] 7 | pull_request: 8 | schedule: 9 | - cron: "0 8 * * *" 10 | 11 | concurrency: 12 | group: check-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | test: 17 | name: test ${{ matrix.env }} - ${{ matrix.os }} 18 | runs-on: ${{ matrix.os }} 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | env: 23 | - "3.13" 24 | - "3.12" 25 | - "3.11" 26 | - "3.10" 27 | - "3.9" 28 | - type 29 | - dev 30 | - pkg_meta 31 | os: 32 | - ubuntu-latest 33 | - windows-latest 34 | - macos-latest 35 | exclude: 36 | - { os: macos-latest, env: "type" } 37 | - { os: macos-latest, env: "dev" } 38 | - { os: macos-latest, env: "pkg_meta" } 39 | steps: 40 | - uses: actions/checkout@v4 41 | with: 42 | fetch-depth: 0 43 | - name: Install the latest version of uv 44 | uses: astral-sh/setup-uv@v6 45 | with: 46 | enable-cache: true 47 | cache-dependency-glob: "pyproject.toml" 48 | github-token: ${{ secrets.GITHUB_TOKEN }} 49 | - name: Add .local/bin to Windows PATH 50 | if: runner.os == 'Windows' 51 | shell: bash 52 | run: echo "$USERPROFILE/.local/bin" >> $GITHUB_PATH 53 | - name: Install tox 54 | run: uv tool install --python-preference only-managed --python 3.13 tox --with tox-uv 55 | - name: Install Python 56 | if: startsWith(matrix.env, '3.') && matrix.env != '3.13' 57 | run: uv python install --python-preference only-managed ${{ matrix.env }} 58 | - name: Setup test suite 59 | run: tox run -vv --notest --skip-missing-interpreters false -e ${{ matrix.env }} 60 | env: 61 | UV_PYTHON_PREFERENCE: only-managed 62 | - name: Run test suite 63 | run: tox run --skip-pkg-install -e ${{ matrix.env }} 64 | env: 65 | PYTEST_ADDOPTS: "-vv --durations=20" 66 | DIFF_AGAINST: HEAD 67 | UV_PYTHON_PREFERENCE: only-managed 68 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release to PyPI 2 | on: 3 | push: 4 | tags: ["*"] 5 | 6 | env: 7 | dists-artifact-name: python-package-distributions 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | - name: Install the latest version of uv 17 | uses: astral-sh/setup-uv@v6 18 | with: 19 | enable-cache: true 20 | cache-dependency-glob: "pyproject.toml" 21 | github-token: ${{ secrets.GITHUB_TOKEN }} 22 | - name: Build package 23 | run: uv build --python 3.13 --python-preference only-managed --sdist --wheel . --out-dir dist 24 | - name: Store the distribution packages 25 | uses: actions/upload-artifact@v4 26 | with: 27 | name: ${{ env.dists-artifact-name }} 28 | path: dist/* 29 | 30 | release: 31 | needs: 32 | - build 33 | runs-on: ubuntu-latest 34 | environment: 35 | name: release 36 | url: https://pypi.org/project/pyproject-api/${{ github.ref_name }} 37 | permissions: 38 | id-token: write 39 | steps: 40 | - name: Download all the dists 41 | uses: actions/download-artifact@v4 42 | with: 43 | name: ${{ env.dists-artifact-name }} 44 | path: dist/ 45 | - name: Publish to PyPI 46 | uses: pypa/gh-action-pypi-publish@v1.12.4 47 | with: 48 | attestations: true 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | *.swp 3 | __pycache__ 4 | /src/pyproject_api/_version.py 5 | build 6 | dist 7 | *.egg-info 8 | .tox 9 | /.*_cache 10 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: end-of-file-fixer 6 | - id: trailing-whitespace 7 | - repo: https://github.com/python-jsonschema/check-jsonschema 8 | rev: 0.33.0 9 | hooks: 10 | - id: check-github-workflows 11 | args: [ "--verbose" ] 12 | - repo: https://github.com/codespell-project/codespell 13 | rev: v2.4.1 14 | hooks: 15 | - id: codespell 16 | additional_dependencies: ["tomli>=2.2.1"] 17 | - repo: https://github.com/tox-dev/tox-ini-fmt 18 | rev: "1.5.0" 19 | hooks: 20 | - id: tox-ini-fmt 21 | args: ["-p", "fix"] 22 | - repo: https://github.com/tox-dev/pyproject-fmt 23 | rev: "v2.6.0" 24 | hooks: 25 | - id: pyproject-fmt 26 | - repo: https://github.com/astral-sh/ruff-pre-commit 27 | rev: "v0.11.11" 28 | hooks: 29 | - id: ruff-format 30 | - id: ruff 31 | args: ["--fix", "--unsafe-fixes", "--exit-non-zero-on-fix"] 32 | - repo: meta 33 | hooks: 34 | - id: check-hooks-apply 35 | - id: check-useless-excludes 36 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | os: ubuntu-22.04 4 | tools: 5 | python: "3" 6 | python: 7 | install: 8 | - method: pip 9 | path: . 10 | extra_requirements: 11 | - docs 12 | sphinx: 13 | builder: html 14 | configuration: docs/conf.py 15 | fail_on_warning: true 16 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making 6 | participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, 7 | disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, 8 | religion, or sexual identity and orientation. 9 | 10 | ## Our Standards 11 | 12 | Examples of behavior that contributes to creating a positive environment include: 13 | 14 | - Using welcoming and inclusive language 15 | - Being respectful of differing viewpoints and experiences 16 | - Gracefully accepting constructive criticism 17 | - Focusing on what is best for the community 18 | - Showing empathy towards other community members 19 | 20 | Examples of unacceptable behavior by participants include: 21 | 22 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 23 | - Trolling, insulting/derogatory comments, and personal or political attacks 24 | - Public or private harassment 25 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 26 | - Other conduct which could reasonably be considered inappropriate in a professional setting 27 | 28 | ## Our Responsibilities 29 | 30 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take 31 | appropriate and fair corrective action in response to any instances of unacceptable behavior. 32 | 33 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, 34 | issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any 35 | contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 36 | 37 | ## Scope 38 | 39 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the 40 | project or its community. Examples of representing a project or community include using an official project e-mail 41 | address, posting via an official social media account, or acting as an appointed representative at an online or offline 42 | event. Representation of a project may be further defined and clarified by project maintainers. 43 | 44 | ## Enforcement 45 | 46 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at 47 | tox-dev@python.org. The project team will review and investigate all complaints, and will respond in a way that it deems 48 | appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter 49 | of an incident. Further details of specific enforcement policies may be posted separately. 50 | 51 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent 52 | repercussions as determined by other members of the project's leadership. 53 | 54 | ## Attribution 55 | 56 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at 57 | [https://www.contributor-covenant.org/version/1/4/code-of-conduct.html][version] 58 | 59 | [homepage]: https://www.contributor-covenant.org/ 60 | [version]: https://www.contributor-covenant.org/version/1/4/ 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a 2 | copy of this software and associated documentation files (the 3 | "Software"), to deal in the Software without restriction, including 4 | without limitation the rights to use, copy, modify, merge, publish, 5 | distribute, sublicense, and/or sell copies of the Software, and to 6 | permit persons to whom the Software is furnished to do so, subject to 7 | the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included 10 | in all copies or substantial portions of the Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 13 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 14 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 15 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 16 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 17 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 18 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [`pyproject-api`](https://pyproject-api.readthedocs.io/en/latest/) 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/pyproject-api?style=flat-square)](https://pypi.org/project/pyproject-api/) 4 | [![Supported Python 5 | versions](https://img.shields.io/pypi/pyversions/pyproject-api.svg)](https://pypi.org/project/pyproject-api/) 6 | [![Downloads](https://static.pepy.tech/badge/pyproject-api/month)](https://pepy.tech/project/pyproject-api) 7 | [![check](https://github.com/tox-dev/pyproject-api/actions/workflows/check.yaml/badge.svg)](https://github.com/tox-dev/pyproject-api/actions/workflows/check.yaml) 8 | [![Documentation Status](https://readthedocs.org/projects/pyproject-api/badge/?version=latest)](https://pyproject-api.readthedocs.io/en/latest/?badge=latest) 9 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | 2 | API 3 | +++ 4 | 5 | .. currentmodule:: pyproject_api 6 | 7 | .. autodata:: __version__ 8 | 9 | Frontend 10 | -------- 11 | .. autoclass:: Frontend 12 | 13 | .. autoclass:: OptionalHooks 14 | 15 | Exceptions 16 | ---------- 17 | 18 | Backend failed 19 | ~~~~~~~~~~~~~~ 20 | .. autoclass:: BackendFailed 21 | 22 | Results 23 | ------- 24 | 25 | Build source distribution requires 26 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 27 | .. autoclass:: RequiresBuildSdistResult 28 | 29 | Build wheel requires 30 | ~~~~~~~~~~~~~~~~~~~~ 31 | .. autoclass:: RequiresBuildWheelResult 32 | 33 | Editable requires 34 | ~~~~~~~~~~~~~~~~~ 35 | .. autoclass:: RequiresBuildEditableResult 36 | 37 | Wheel metadata 38 | ~~~~~~~~~~~~~~ 39 | .. autoclass:: MetadataForBuildWheelResult 40 | 41 | Editable metadata 42 | ~~~~~~~~~~~~~~~~~ 43 | .. autoclass:: MetadataForBuildEditableResult 44 | 45 | Source distribution 46 | ~~~~~~~~~~~~~~~~~~~ 47 | .. autoclass:: SdistResult 48 | 49 | Editable 50 | ~~~~~~~~ 51 | .. autoclass:: EditableResult 52 | 53 | Wheel 54 | ~~~~~ 55 | .. autoclass:: WheelResult 56 | 57 | Fresh subprocess frontend 58 | ------------------------- 59 | .. autoclass:: SubprocessFrontend 60 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # noqa: D100 2 | from __future__ import annotations 3 | 4 | from typing import TYPE_CHECKING 5 | 6 | from sphinx.domains.python import PythonDomain 7 | 8 | from pyproject_api import __version__ 9 | 10 | if TYPE_CHECKING: 11 | from docutils.nodes import Element 12 | from sphinx.application import Sphinx 13 | from sphinx.builders import Builder 14 | from sphinx.environment import BuildEnvironment 15 | project = name = "pyproject_api" 16 | company = "tox-dev" 17 | project_copyright = f"{company}" 18 | version, release = __version__, __version__.split("+")[0] 19 | 20 | extensions = [ 21 | "sphinx.ext.autosectionlabel", 22 | "sphinx.ext.extlinks", 23 | "sphinx.ext.autodoc", 24 | "sphinx_autodoc_typehints", 25 | "sphinx.ext.viewcode", 26 | "sphinx.ext.intersphinx", 27 | ] 28 | master_doc, source_suffix = "index", ".rst" 29 | 30 | html_theme = "furo" 31 | html_title, html_last_updated_fmt = "pyproject-api docs", "%Y-%m-%dT%H:%M:%S" 32 | pygments_style, pygments_dark_style = "sphinx", "monokai" 33 | 34 | autoclass_content, autodoc_typehints = "both", "none" 35 | autodoc_default_options = {"members": True, "member-order": "bysource", "undoc-members": True, "show-inheritance": True} 36 | inheritance_alias = {} 37 | 38 | extlinks = { 39 | "issue": ("https://github.com/tox-dev/pyproject-api/issues/%s", "#%s"), 40 | "pull": ("https://github.com/tox-dev/pyproject-api/pull/%s", "PR #%s"), 41 | "user": ("https://github.com/%s", "@%s"), 42 | } 43 | intersphinx_mapping = { 44 | "python": ("https://docs.python.org/3", None), 45 | "packaging": ("https://packaging.pypa.io/en/latest", None), 46 | } 47 | 48 | nitpicky = True 49 | nitpick_ignore = [] 50 | 51 | 52 | def setup(app: Sphinx) -> None: # noqa: D103 53 | class PatchedPythonDomain(PythonDomain): 54 | def resolve_xref( # noqa: PLR0913,PLR0917 55 | self, 56 | env: BuildEnvironment, 57 | fromdocname: str, 58 | builder: Builder, 59 | type: str, # noqa: A002 60 | target: str, 61 | node: resolve_xref, 62 | contnode: Element, 63 | ) -> Element: 64 | # fixup some wrongly resolved mappings 65 | mapping = { 66 | "pathlib._local.Path": "pathlib.Path", 67 | } 68 | if target in mapping: 69 | target = node["reftarget"] = mapping[target] 70 | return super().resolve_xref(env, fromdocname, builder, type, target, node, contnode) 71 | 72 | app.add_domain(PatchedPythonDomain, override=True) 73 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ``pyproject-api`` 2 | ================= 3 | 4 | ``pyproject-api`` aims to abstract away interaction with ``pyproject.toml`` style projects in a flexible way. 5 | 6 | .. toctree:: 7 | :hidden: 8 | 9 | self 10 | api 11 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = [ 4 | "hatch-vcs>=0.4", 5 | "hatchling>=1.27", 6 | ] 7 | 8 | [project] 9 | name = "pyproject-api" 10 | description = "API to interact with the python pyproject.toml based projects" 11 | readme.content-type = "text/markdown" 12 | readme.file = "README.md" 13 | keywords = [ 14 | "environments", 15 | "isolated", 16 | "testing", 17 | "virtual", 18 | ] 19 | license = "MIT" 20 | maintainers = [ 21 | { name = "Bernát Gábor", email = "gaborjbernat@gmail.com" }, 22 | ] 23 | authors = [ 24 | { name = "Bernát Gábor", email = "gaborjbernat@gmail.com" }, 25 | ] 26 | requires-python = ">=3.9" 27 | classifiers = [ 28 | "Development Status :: 5 - Production/Stable", 29 | "Framework :: tox", 30 | "Intended Audience :: Developers", 31 | "License :: OSI Approved :: MIT License", 32 | "Operating System :: MacOS :: MacOS X", 33 | "Operating System :: Microsoft :: Windows", 34 | "Operating System :: POSIX", 35 | "Programming Language :: Python :: 3 :: Only", 36 | "Programming Language :: Python :: 3.9", 37 | "Programming Language :: Python :: 3.10", 38 | "Programming Language :: Python :: 3.11", 39 | "Programming Language :: Python :: 3.12", 40 | "Programming Language :: Python :: 3.13", 41 | "Topic :: Software Development :: Libraries", 42 | "Topic :: Software Development :: Testing", 43 | "Topic :: Utilities", 44 | ] 45 | dynamic = [ 46 | "version", 47 | ] 48 | dependencies = [ 49 | "packaging>=25", 50 | "tomli>=2.2.1; python_version<'3.11'", 51 | ] 52 | optional-dependencies.docs = [ 53 | "furo>=2024.8.6", 54 | "sphinx-autodoc-typehints>=3.2", 55 | ] 56 | optional-dependencies.testing = [ 57 | "covdefaults>=2.3", 58 | "pytest>=8.3.5", 59 | "pytest-cov>=6.1.1", 60 | "pytest-mock>=3.14", 61 | "setuptools>=80.3.1", 62 | ] 63 | urls.Changelog = "https://github.com/tox-dev/pyproject-api/releases" 64 | urls.Homepage = "https://pyproject-api.readthedocs.io" 65 | urls.Source = "https://github.com/tox-dev/pyproject-api" 66 | urls.Tracker = "https://github.com/tox-dev/pyproject-api/issues" 67 | 68 | [tool.hatch] 69 | build.hooks.vcs.version-file = "src/pyproject_api/_version.py" 70 | version.source = "vcs" 71 | 72 | [tool.ruff] 73 | line-length = 120 74 | format.preview = true 75 | format.docstring-code-line-length = 100 76 | format.docstring-code-format = true 77 | lint.select = [ 78 | "ALL", 79 | ] 80 | lint.ignore = [ 81 | "ANN401", # Dynamically typed expressions 82 | "COM812", # Conflict with formatter 83 | "CPY", # No copyright statements 84 | "D203", # `one-blank-line-before-class` (D203) and `no-blank-line-before-class` (D211) are incompatible 85 | "D212", # `multi-line-summary-first-line` (D212) and `multi-line-summary-second-line` (D213) are incompatible 86 | "DOC", # no restructuredtext support 87 | "INP001", # no implicit namespaces here 88 | "ISC001", # Conflict with formatter 89 | "S104", # Possible binding to all interface 90 | ] 91 | lint.per-file-ignores."src/pyproject_api/_backend.py" = [ 92 | "ANN", 93 | "I002", 94 | "T201", 95 | "UP", 96 | ] # no type annotations 97 | lint.per-file-ignores."src/pyproject_api/_backend.pyi" = [ 98 | "E301", 99 | "E302", 100 | ] # https://github.com/astral-sh/ruff/issues/10077 101 | lint.per-file-ignores."tests/**/*.py" = [ 102 | "D", # don't care about documentation in tests 103 | "FBT", # don't care about booleans as positional arguments in tests 104 | "INP001", # no implicit namespace 105 | "PLC2701", # private imports 106 | "PLR0913", # too many positional arguments 107 | "PLR0917", # too many positional arguments 108 | "PLR2004", # Magic value used in comparison, consider replacing with a constant variable 109 | "S101", # asserts allowed in tests 110 | "S603", # `subprocess` call: check for execution of untrusted input 111 | ] 112 | lint.isort = { known-first-party = [ 113 | "pyproject_api", 114 | ], required-imports = [ 115 | "from __future__ import annotations", 116 | ] } 117 | lint.preview = true 118 | 119 | [tool.codespell] 120 | builtin = "clear,usage,en-GB_to_en-US" 121 | write-changes = true 122 | count = true 123 | 124 | [tool.pyproject-fmt] 125 | max_supported_python = "3.13" 126 | 127 | [tool.coverage] 128 | html.show_contexts = true 129 | html.skip_covered = false 130 | paths.source = [ 131 | "src", 132 | ".tox*/*/lib/python*/site-packages", 133 | ".tox*/pypy*/site-packages", 134 | ".tox*\\*\\Lib\\site-packages", 135 | "*/src", 136 | "*\\src", 137 | ] 138 | report.omit = [ 139 | ] 140 | run.parallel = true 141 | run.plugins = [ 142 | "covdefaults", 143 | ] 144 | 145 | [tool.mypy] 146 | python_version = "3.12" 147 | show_error_codes = true 148 | strict = true 149 | overrides = [ 150 | { module = [ 151 | "virtualenv.*", 152 | ], ignore_missing_imports = true }, 153 | ] 154 | -------------------------------------------------------------------------------- /src/pyproject_api/__init__.py: -------------------------------------------------------------------------------- 1 | """PyProject API interface.""" 2 | 3 | from __future__ import annotations 4 | 5 | from ._frontend import ( 6 | BackendFailed, 7 | CmdStatus, 8 | EditableResult, 9 | Frontend, 10 | MetadataForBuildEditableResult, 11 | MetadataForBuildWheelResult, 12 | OptionalHooks, 13 | RequiresBuildEditableResult, 14 | RequiresBuildSdistResult, 15 | RequiresBuildWheelResult, 16 | SdistResult, 17 | WheelResult, 18 | ) 19 | from ._version import version 20 | from ._via_fresh_subprocess import SubprocessFrontend 21 | 22 | #: semantic version of the project 23 | __version__ = version 24 | 25 | __all__ = [ 26 | "BackendFailed", 27 | "CmdStatus", 28 | "EditableResult", 29 | "Frontend", 30 | "MetadataForBuildEditableResult", 31 | "MetadataForBuildWheelResult", 32 | "OptionalHooks", 33 | "RequiresBuildEditableResult", 34 | "RequiresBuildSdistResult", 35 | "RequiresBuildWheelResult", 36 | "SdistResult", 37 | "SubprocessFrontend", 38 | "WheelResult", 39 | "__version__", 40 | ] 41 | -------------------------------------------------------------------------------- /src/pyproject_api/__main__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations # noqa: D100 2 | 3 | import argparse 4 | import os 5 | import pathlib 6 | import sys 7 | from typing import TYPE_CHECKING 8 | 9 | from ._via_fresh_subprocess import SubprocessFrontend 10 | 11 | if TYPE_CHECKING: 12 | from ._frontend import EditableResult, SdistResult, WheelResult 13 | 14 | 15 | def main_parser() -> argparse.ArgumentParser: # noqa: D103 16 | parser = argparse.ArgumentParser( 17 | description=( 18 | "A pyproject.toml-based build frontend. " 19 | "This is mainly useful for debugging PEP-517 backends. " 20 | "This frontend will not do things like install required build dependencies." 21 | ), 22 | ) 23 | parser.add_argument( 24 | "srcdir", 25 | type=pathlib.Path, 26 | nargs="?", 27 | default=pathlib.Path.cwd(), 28 | help="source directory (defaults to current directory)", 29 | ) 30 | parser.add_argument( 31 | "--sdist", 32 | "-s", 33 | dest="distributions", 34 | action="append_const", 35 | const="sdist", 36 | default=[], 37 | help="build a source distribution", 38 | ) 39 | parser.add_argument( 40 | "--wheel", 41 | "-w", 42 | dest="distributions", 43 | action="append_const", 44 | const="wheel", 45 | help="build a wheel distribution", 46 | ) 47 | parser.add_argument( 48 | "--editable", 49 | "-e", 50 | dest="distributions", 51 | action="append_const", 52 | const="editable", 53 | help="build an editable wheel distribution", 54 | ) 55 | parser.add_argument( 56 | "--outdir", 57 | "-o", 58 | type=pathlib.Path, 59 | help=f"output directory (defaults to {{srcdir}}{os.sep}dist)", 60 | ) 61 | return parser 62 | 63 | 64 | def main(argv: list[str]) -> None: # noqa: D103 65 | parser = main_parser() 66 | args = parser.parse_args(argv) 67 | 68 | outdir = args.outdir or args.srcdir / "dist" 69 | # we intentionally do not build editable distributions by default 70 | distributions = args.distributions or ["sdist", "wheel"] 71 | 72 | frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(args.srcdir)[:-1]) 73 | res: SdistResult | WheelResult | EditableResult 74 | 75 | if "sdist" in distributions: 76 | print("Building sdist...") # noqa: T201 77 | res = frontend.build_sdist(outdir) 78 | print(res.out) # noqa: T201 79 | print(res.err, file=sys.stderr) # noqa: T201 80 | 81 | if "wheel" in distributions: 82 | print("Building wheel...") # noqa: T201 83 | res = frontend.build_wheel(outdir) 84 | print(res.out) # noqa: T201 85 | print(res.err, file=sys.stderr) # noqa: T201 86 | 87 | if "editable" in distributions: 88 | print("Building editable wheel...") # noqa: T201 89 | res = frontend.build_editable(outdir) 90 | print(res.out) # noqa: T201 91 | print(res.err, file=sys.stderr) # noqa: T201 92 | 93 | 94 | if __name__ == "__main__": 95 | main(sys.argv[1:]) 96 | -------------------------------------------------------------------------------- /src/pyproject_api/_backend.py: -------------------------------------------------------------------------------- 1 | """ 2 | Handles communication on the backend side between frontend and backend. 3 | 4 | Please keep this file Python 2.7 compatible. 5 | See https://tox.readthedocs.io/en/rewrite/development.html#code-style-guide 6 | """ 7 | 8 | import importlib 9 | import json 10 | import locale 11 | import os 12 | import sys 13 | import traceback 14 | 15 | 16 | class MissingCommand(TypeError): # noqa: N818 17 | """Missing command.""" 18 | 19 | 20 | class BackendProxy: 21 | def __init__(self, backend_module, backend_obj): 22 | self.backend_module = backend_module 23 | self.backend_object = backend_obj 24 | backend = importlib.import_module(self.backend_module) 25 | if self.backend_object: 26 | backend = getattr(backend, self.backend_object) 27 | self.backend = backend 28 | 29 | def __call__(self, name, *args, **kwargs): 30 | on_object = self if name.startswith("_") else self.backend 31 | if not hasattr(on_object, name): 32 | msg = f"{on_object!r} has no attribute {name!r}" 33 | raise MissingCommand(msg) 34 | return getattr(on_object, name)(*args, **kwargs) 35 | 36 | def __str__(self): 37 | return f"{self.__class__.__name__}(backend={self.backend})" 38 | 39 | def _exit(self): # noqa: PLR6301 40 | return 0 41 | 42 | def _optional_hooks(self): 43 | return { 44 | k: hasattr(self.backend, k) 45 | for k in ( 46 | "get_requires_for_build_sdist", 47 | "prepare_metadata_for_build_wheel", 48 | "get_requires_for_build_wheel", 49 | "build_editable", 50 | "get_requires_for_build_editable", 51 | "prepare_metadata_for_build_editable", 52 | ) 53 | } 54 | 55 | 56 | def flush(): 57 | sys.stderr.flush() 58 | sys.stdout.flush() 59 | 60 | 61 | def run(argv): # noqa: C901, PLR0912, PLR0915 62 | reuse_process = argv[0].lower() == "true" 63 | 64 | try: 65 | backend_proxy = BackendProxy(argv[1], None if len(argv) == 2 else argv[2]) # noqa: PLR2004 66 | except BaseException: 67 | print("failed to start backend", file=sys.stderr) 68 | raise 69 | else: 70 | print(f"started backend {backend_proxy}", file=sys.stdout) 71 | finally: 72 | flush() # pragma: no branch 73 | while True: 74 | content = read_line() 75 | if not content: 76 | continue 77 | flush() # flush any output generated before 78 | try: 79 | # python 2 does not support loading from bytearray 80 | if sys.version_info[0] == 2: # pragma: no branch # noqa: PLR2004 81 | content = content.decode() # pragma: no cover 82 | parsed_message = json.loads(content) 83 | result_file = parsed_message["result"] 84 | except Exception: # noqa: BLE001 85 | # ignore messages that are not valid JSON and contain a valid result path 86 | print(f"Backend: incorrect request to backend: {content}", file=sys.stderr) 87 | flush() 88 | else: 89 | result = {} 90 | try: 91 | cmd = parsed_message["cmd"] 92 | print("Backend: run command {} with args {}".format(cmd, parsed_message["kwargs"])) 93 | outcome = backend_proxy(parsed_message["cmd"], **parsed_message["kwargs"]) 94 | result["return"] = outcome 95 | if cmd == "_exit": 96 | break 97 | except BaseException as exception: # noqa: BLE001 98 | result["code"] = exception.code if isinstance(exception, SystemExit) else 1 99 | result["exc_type"] = exception.__class__.__name__ 100 | result["exc_msg"] = str(exception) 101 | if not isinstance(exception, MissingCommand): # for missing command do not print stack 102 | traceback.print_exc() 103 | finally: 104 | try: 105 | encoding = locale.getpreferredencoding(do_setlocale=False) 106 | with open(result_file, "w", encoding=encoding) as file_handler: # noqa: PTH123 107 | json.dump(result, file_handler) 108 | except Exception: # noqa: BLE001 109 | traceback.print_exc() 110 | finally: 111 | # used as done marker by frontend 112 | print(f"Backend: Wrote response {result} to {result_file}") 113 | flush() # pragma: no branch 114 | if reuse_process is False: # pragma: no branch # no test for reuse process in root test env 115 | break 116 | return 0 117 | 118 | 119 | def read_line(fd=0): 120 | # for some reason input() seems to break (hangs forever) so instead we read byte by byte the unbuffered stream 121 | content = bytearray() 122 | while True: 123 | char = os.read(fd, 1) 124 | if not char: 125 | if not content: 126 | msg = "EOF without reading anything" 127 | raise EOFError(msg) # we didn't get a line at all, let the caller know 128 | break # pragma: no cover 129 | if char == b"\n": 130 | break 131 | if char != b"\r": 132 | content += char 133 | return content 134 | 135 | 136 | if __name__ == "__main__": 137 | sys.exit(run(sys.argv[1:])) 138 | -------------------------------------------------------------------------------- /src/pyproject_api/_backend.pyi: -------------------------------------------------------------------------------- 1 | from collections.abc import Sequence 2 | from typing import Any 3 | 4 | class MissingCommand(TypeError): ... # noqa: N818 5 | 6 | class BackendProxy: 7 | backend_module: str 8 | backend_object: str | None 9 | backend: Any 10 | 11 | def __init__(self, backend_module: str, backend_obj: str | None) -> None: ... 12 | def __call__(self, name: str, *args: Any, **kwargs: Any) -> Any: ... 13 | def _exit(self) -> None: ... 14 | def _optional_commands(self) -> dict[str, bool]: ... 15 | 16 | def run(argv: Sequence[str]) -> int: ... 17 | def read_line(fd: int = 0) -> bytearray: ... 18 | def flush() -> None: ... 19 | -------------------------------------------------------------------------------- /src/pyproject_api/_frontend.py: -------------------------------------------------------------------------------- 1 | """Build frontend for PEP-517.""" 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | import sys 7 | from abc import ABC, abstractmethod 8 | from contextlib import contextmanager 9 | from pathlib import Path 10 | from tempfile import NamedTemporaryFile, TemporaryDirectory 11 | from time import sleep 12 | from typing import TYPE_CHECKING, Any, Literal, NamedTuple, NoReturn, Optional, TypedDict, cast 13 | from zipfile import ZipFile 14 | 15 | from packaging.requirements import Requirement 16 | 17 | from pyproject_api._util import ensure_empty_dir 18 | 19 | if TYPE_CHECKING: 20 | from collections.abc import Iterator 21 | 22 | if sys.version_info >= (3, 11): # pragma: no cover (py311+) 23 | import tomllib 24 | else: # pragma: no cover (py311+) 25 | import tomli as tomllib 26 | 27 | _HERE = Path(__file__).parent 28 | ConfigSettings = Optional[dict[str, Any]] 29 | 30 | 31 | class OptionalHooks(TypedDict, total=True): 32 | """A flag indicating if the backend supports the optional hook or not.""" 33 | 34 | get_requires_for_build_sdist: bool 35 | prepare_metadata_for_build_wheel: bool 36 | get_requires_for_build_wheel: bool 37 | build_editable: bool 38 | get_requires_for_build_editable: bool 39 | prepare_metadata_for_build_editable: bool 40 | 41 | 42 | class CmdStatus(ABC): 43 | @property 44 | @abstractmethod 45 | def done(self) -> bool: 46 | """:return: truthful when the command finished running""" 47 | raise NotImplementedError 48 | 49 | @abstractmethod 50 | def out_err(self) -> tuple[str, str]: 51 | """:return: standard output and standard error text""" 52 | raise NotImplementedError 53 | 54 | 55 | class RequiresBuildSdistResult(NamedTuple): 56 | """Information collected while acquiring the source distribution build dependencies.""" 57 | 58 | #: wheel build dependencies 59 | requires: tuple[Requirement, ...] 60 | #: backend standard output while acquiring the source distribution build dependencies 61 | out: str 62 | #: backend standard output while acquiring the source distribution build dependencies 63 | err: str 64 | 65 | 66 | class RequiresBuildWheelResult(NamedTuple): 67 | """Information collected while acquiring the wheel build dependencies.""" 68 | 69 | #: wheel build dependencies 70 | requires: tuple[Requirement, ...] 71 | #: backend standard output while acquiring the wheel build dependencies 72 | out: str 73 | #: backend standard error while acquiring the wheel build dependencies 74 | err: str 75 | 76 | 77 | class RequiresBuildEditableResult(NamedTuple): 78 | """Information collected while acquiring the wheel build dependencies.""" 79 | 80 | #: editable wheel build dependencies 81 | requires: tuple[Requirement, ...] 82 | #: backend standard output while acquiring the editable wheel build dependencies 83 | out: str 84 | #: backend standard error while acquiring the editable wheel build dependencies 85 | err: str 86 | 87 | 88 | class MetadataForBuildWheelResult(NamedTuple): 89 | """Information collected while acquiring the wheel metadata.""" 90 | 91 | #: path to the wheel metadata 92 | metadata: Path 93 | #: backend standard output while generating the wheel metadata 94 | out: str 95 | #: backend standard output while generating the wheel metadata 96 | err: str 97 | 98 | 99 | class MetadataForBuildEditableResult(NamedTuple): 100 | """Information collected while acquiring the editable metadata.""" 101 | 102 | #: path to the wheel metadata 103 | metadata: Path 104 | #: backend standard output while generating the editable wheel metadata 105 | out: str 106 | #: backend standard output while generating the editable wheel metadata 107 | err: str 108 | 109 | 110 | class SdistResult(NamedTuple): 111 | """Information collected while building a source distribution.""" 112 | 113 | #: path to the built source distribution 114 | sdist: Path 115 | #: backend standard output while building the source distribution 116 | out: str 117 | #: backend standard output while building the source distribution 118 | err: str 119 | 120 | 121 | class WheelResult(NamedTuple): 122 | """Information collected while building a wheel.""" 123 | 124 | #: path to the built wheel artifact 125 | wheel: Path 126 | #: backend standard output while building the wheel 127 | out: str 128 | #: backend standard error while building the wheel 129 | err: str 130 | 131 | 132 | class EditableResult(NamedTuple): 133 | """Information collected while building an editable wheel.""" 134 | 135 | #: path to the built wheel artifact 136 | wheel: Path 137 | #: backend standard output while building the wheel 138 | out: str 139 | #: backend standard error while building the wheel 140 | err: str 141 | 142 | 143 | class BackendFailed(RuntimeError): # noqa: N818 144 | """An error of the build backend.""" 145 | 146 | def __init__(self, result: dict[str, Any], out: str, err: str) -> None: 147 | super().__init__() 148 | #: standard output collected while running the command 149 | self.out = out 150 | #: standard error collected while running the command 151 | self.err = err 152 | #: exit code of the command 153 | self.code: int = result.get("code", -2) 154 | #: the type of exception thrown 155 | self.exc_type: str = result.get("exc_type", "missing Exception type") 156 | #: the string representation of the exception thrown 157 | self.exc_msg: str = result.get("exc_msg", "missing Exception message") 158 | 159 | def __str__(self) -> str: 160 | return ( 161 | f"packaging backend failed{'' if self.code is None else f' (code={self.code})'}, " 162 | f"with {self.exc_type}: {self.exc_msg}\n{self.err}{self.out}" 163 | ).rstrip() 164 | 165 | def __repr__(self) -> str: 166 | return ( 167 | f"{self.__class__.__name__}(" 168 | f"result=dict(code={self.code}, exc_type={self.exc_type!r},exc_msg={self.exc_msg!r})," 169 | f" out={self.out!r}, err={self.err!r})" 170 | ) 171 | 172 | 173 | class Frontend(ABC): 174 | """Abstract base class for a pyproject frontend.""" 175 | 176 | #: backend key when the ``pyproject.toml`` does not specify it 177 | LEGACY_BUILD_BACKEND: str = "setuptools.build_meta:__legacy__" 178 | #: backend requirements when the ``pyproject.toml`` does not specify it 179 | LEGACY_REQUIRES: tuple[Requirement, ...] = (Requirement("setuptools >= 40.8.0"),) 180 | 181 | def __init__( # noqa: PLR0913, PLR0917 182 | self, 183 | root: Path, 184 | backend_paths: tuple[Path, ...], 185 | backend_module: str, 186 | backend_obj: str | None, 187 | requires: tuple[Requirement, ...], 188 | reuse_backend: bool = True, # noqa: FBT001, FBT002 189 | ) -> None: 190 | """ 191 | Create a new frontend. 192 | 193 | :param root: the root path of the project 194 | :param backend_paths: paths to provision as available to import from for the build backend 195 | :param backend_module: the module where the backend lives 196 | :param backend_obj: the backend object key (will be lookup up within the backend module) 197 | :param requires: build requirements for the backend 198 | :param reuse_backend: a flag indicating if the communication channel should be kept alive between messages 199 | """ 200 | self._root = root 201 | self._backend_paths = backend_paths 202 | self._backend_module = backend_module 203 | self._backend_obj = backend_obj 204 | self.requires: tuple[Requirement, ...] = requires 205 | self._reuse_backend = reuse_backend 206 | self._optional_hooks: OptionalHooks | None = None 207 | 208 | @classmethod 209 | def create_args_from_folder( 210 | cls, 211 | folder: Path, 212 | ) -> tuple[Path, tuple[Path, ...], str, str | None, tuple[Requirement, ...], bool]: 213 | """ 214 | Frontend creation arguments from a python project folder (thould have a ``pypyproject.toml`` file per PEP-518). 215 | 216 | :param folder: the python project folder 217 | :return: the frontend creation args 218 | 219 | E.g., to create a frontend from a python project folder: 220 | 221 | .. code:: python 222 | 223 | frontend = Frontend(*Frontend.create_args_from_folder(project_folder)) 224 | """ 225 | py_project_toml = folder / "pyproject.toml" 226 | if py_project_toml.exists(): 227 | with py_project_toml.open("rb") as file_handler: 228 | py_project = tomllib.load(file_handler) 229 | build_system = py_project.get("build-system", {}) 230 | if "backend-path" in build_system: 231 | backend_paths: tuple[Path, ...] = tuple(folder / p for p in build_system["backend-path"]) 232 | else: 233 | backend_paths = () 234 | if "requires" in build_system: 235 | requires: tuple[Requirement, ...] = tuple(Requirement(r) for r in build_system.get("requires")) 236 | else: 237 | requires = cls.LEGACY_REQUIRES 238 | build_backend = build_system.get("build-backend", cls.LEGACY_BUILD_BACKEND) 239 | else: 240 | backend_paths = () 241 | requires = cls.LEGACY_REQUIRES 242 | build_backend = cls.LEGACY_BUILD_BACKEND 243 | paths = build_backend.split(":") 244 | backend_module: str = paths[0] 245 | backend_obj: str | None = paths[1] if len(paths) > 1 else None 246 | return folder, backend_paths, backend_module, backend_obj, requires, True 247 | 248 | @property 249 | def backend(self) -> str: 250 | """:return: backend key""" 251 | return f"{self._backend_module}{f':{self._backend_obj}' if self._backend_obj else ''}" 252 | 253 | @property 254 | def backend_args(self) -> list[str]: 255 | """:return: startup arguments for a backend""" 256 | result: list[str] = [str(_HERE / "_backend.py"), str(self._reuse_backend), self._backend_module] 257 | if self._backend_obj: 258 | result.append(self._backend_obj) 259 | return result 260 | 261 | @property 262 | def optional_hooks(self) -> OptionalHooks: 263 | """:return: a dictionary indicating if the optional hook is supported or not""" 264 | if self._optional_hooks is None: 265 | result, _, __ = self._send("_optional_hooks") 266 | self._optional_hooks = result 267 | return self._optional_hooks 268 | 269 | def get_requires_for_build_sdist(self, config_settings: ConfigSettings | None = None) -> RequiresBuildSdistResult: 270 | """ 271 | Get build requirements for a source distribution (per PEP-517). 272 | 273 | :param config_settings: run arguments 274 | :return: outcome 275 | """ 276 | if self.optional_hooks["get_requires_for_build_sdist"]: 277 | result, out, err = self._send(cmd="get_requires_for_build_sdist", config_settings=config_settings) 278 | else: 279 | result, out, err = [], "", "" 280 | if not isinstance(result, list) or not all(isinstance(i, str) for i in result): 281 | self._unexpected_response("get_requires_for_build_sdist", result, "list of string", out, err) 282 | return RequiresBuildSdistResult(tuple(Requirement(r) for r in cast("list[str]", result)), out, err) 283 | 284 | def get_requires_for_build_wheel(self, config_settings: ConfigSettings | None = None) -> RequiresBuildWheelResult: 285 | """ 286 | Get build requirements for a wheel (per PEP-517). 287 | 288 | :param config_settings: run arguments 289 | :return: outcome 290 | """ 291 | if self.optional_hooks["get_requires_for_build_wheel"]: 292 | result, out, err = self._send(cmd="get_requires_for_build_wheel", config_settings=config_settings) 293 | else: 294 | result, out, err = [], "", "" 295 | if not isinstance(result, list) or not all(isinstance(i, str) for i in result): 296 | self._unexpected_response("get_requires_for_build_wheel", result, "list of string", out, err) 297 | return RequiresBuildWheelResult(tuple(Requirement(r) for r in cast("list[str]", result)), out, err) 298 | 299 | def get_requires_for_build_editable( 300 | self, 301 | config_settings: ConfigSettings | None = None, 302 | ) -> RequiresBuildEditableResult: 303 | """ 304 | Get build requirements for an editable wheel build (per PEP-660). 305 | 306 | :param config_settings: run arguments 307 | :return: outcome 308 | """ 309 | if self.optional_hooks["get_requires_for_build_editable"]: 310 | result, out, err = self._send(cmd="get_requires_for_build_editable", config_settings=config_settings) 311 | else: 312 | result, out, err = [], "", "" 313 | if not isinstance(result, list) or not all(isinstance(i, str) for i in result): 314 | self._unexpected_response("get_requires_for_build_editable", result, "list of string", out, err) 315 | return RequiresBuildEditableResult(tuple(Requirement(r) for r in cast("list[str]", result)), out, err) 316 | 317 | def prepare_metadata_for_build_wheel( 318 | self, 319 | metadata_directory: Path, 320 | config_settings: ConfigSettings | None = None, 321 | ) -> MetadataForBuildWheelResult | None: 322 | """ 323 | Build wheel metadata (per PEP-517). 324 | 325 | :param metadata_directory: where to generate the metadata 326 | :param config_settings: build arguments 327 | :return: metadata generation result 328 | """ 329 | self._check_metadata_dir(metadata_directory) 330 | basename: str | None = None 331 | if self.optional_hooks["prepare_metadata_for_build_wheel"]: 332 | basename, out, err = self._send( 333 | cmd="prepare_metadata_for_build_wheel", 334 | metadata_directory=metadata_directory, 335 | config_settings=config_settings, 336 | ) 337 | if basename is None: 338 | return None 339 | if not isinstance(basename, str): 340 | self._unexpected_response("prepare_metadata_for_build_wheel", basename, str, out, err) 341 | return MetadataForBuildWheelResult(metadata_directory / basename, out, err) 342 | 343 | def _check_metadata_dir(self, metadata_directory: Path) -> None: 344 | if metadata_directory == self._root: 345 | msg = f"the project root and the metadata directory can't be the same {self._root}" 346 | raise RuntimeError(msg) 347 | if metadata_directory.exists(): # start with fresh 348 | ensure_empty_dir(metadata_directory) 349 | metadata_directory.mkdir(parents=True, exist_ok=True) 350 | 351 | def prepare_metadata_for_build_editable( 352 | self, 353 | metadata_directory: Path, 354 | config_settings: ConfigSettings | None = None, 355 | ) -> MetadataForBuildEditableResult | None: 356 | """ 357 | Build editable wheel metadata (per PEP-660). 358 | 359 | :param metadata_directory: where to generate the metadata 360 | :param config_settings: build arguments 361 | :return: metadata generation result 362 | """ 363 | self._check_metadata_dir(metadata_directory) 364 | basename: str | None = None 365 | if self.optional_hooks["prepare_metadata_for_build_editable"]: 366 | basename, out, err = self._send( 367 | cmd="prepare_metadata_for_build_editable", 368 | metadata_directory=metadata_directory, 369 | config_settings=config_settings, 370 | ) 371 | if basename is None: 372 | return None 373 | if not isinstance(basename, str): 374 | self._unexpected_response("prepare_metadata_for_build_wheel", basename, str, out, err) 375 | result = metadata_directory / basename 376 | return MetadataForBuildEditableResult(result, out, err) 377 | 378 | def build_sdist(self, sdist_directory: Path, config_settings: ConfigSettings | None = None) -> SdistResult: 379 | """ 380 | Build a source distribution (per PEP-517). 381 | 382 | :param sdist_directory: the folder where to build the source distribution 383 | :param config_settings: build arguments 384 | :return: source distribution build result 385 | """ 386 | sdist_directory.mkdir(parents=True, exist_ok=True) 387 | basename, out, err = self._send( 388 | cmd="build_sdist", 389 | sdist_directory=sdist_directory, 390 | config_settings=config_settings, 391 | ) 392 | if not isinstance(basename, str): 393 | self._unexpected_response("build_sdist", basename, str, out, err) 394 | return SdistResult(sdist_directory / basename, out, err) 395 | 396 | def build_wheel( 397 | self, 398 | wheel_directory: Path, 399 | config_settings: ConfigSettings | None = None, 400 | metadata_directory: Path | None = None, 401 | ) -> WheelResult: 402 | """ 403 | Build a wheel file (per PEP-517). 404 | 405 | :param wheel_directory: the folder where to build the wheel 406 | :param config_settings: build arguments 407 | :param metadata_directory: wheel metadata folder 408 | :return: wheel build result 409 | """ 410 | wheel_directory.mkdir(parents=True, exist_ok=True) 411 | basename, out, err = self._send( 412 | cmd="build_wheel", 413 | wheel_directory=wheel_directory, 414 | config_settings=config_settings, 415 | metadata_directory=metadata_directory, 416 | ) 417 | if not isinstance(basename, str): 418 | self._unexpected_response("build_wheel", basename, str, out, err) 419 | return WheelResult(wheel_directory / basename, out, err) 420 | 421 | def build_editable( 422 | self, 423 | wheel_directory: Path, 424 | config_settings: ConfigSettings | None = None, 425 | metadata_directory: Path | None = None, 426 | ) -> EditableResult: 427 | """ 428 | Build an editable wheel file (per PEP-660). 429 | 430 | :param wheel_directory: the folder where to build the editable wheel 431 | :param config_settings: build arguments 432 | :param metadata_directory: wheel metadata folder 433 | :return: wheel build result 434 | """ 435 | wheel_directory.mkdir(parents=True, exist_ok=True) 436 | basename, out, err = self._send( 437 | cmd="build_editable", 438 | wheel_directory=wheel_directory, 439 | config_settings=config_settings, 440 | metadata_directory=metadata_directory, 441 | ) 442 | if not isinstance(basename, str): 443 | self._unexpected_response("build_editable", basename, str, out, err) 444 | return EditableResult(wheel_directory / basename, out, err) 445 | 446 | def _unexpected_response( 447 | self, 448 | cmd: str, 449 | got: Any, 450 | expected_type: Any, 451 | out: str, 452 | err: str, 453 | ) -> NoReturn: 454 | msg = f"{cmd!r} on {self.backend!r} returned {got!r} but expected type {expected_type!r}" 455 | raise BackendFailed({"code": None, "exc_type": TypeError.__name__, "exc_msg": msg}, out, err) 456 | 457 | def metadata_from_built( 458 | self, 459 | metadata_directory: Path, 460 | target: Literal["wheel", "editable"], 461 | config_settings: ConfigSettings | None = None, 462 | ) -> tuple[Path, str, str]: 463 | """ 464 | Create metadata from building the wheel (use when the prepare endpoints are not present or don't work). 465 | 466 | :param metadata_directory: directory where to put the metadata 467 | :param target: the type of wheel metadata to build 468 | :param config_settings: config settings to pass in to the build endpoint 469 | :return: 470 | """ 471 | hook = getattr(self, f"build_{target}") 472 | with self._wheel_directory() as wheel_directory: 473 | result: EditableResult | WheelResult = hook(wheel_directory, config_settings) 474 | wheel = result.wheel 475 | if not wheel.exists(): 476 | msg = f"missing wheel file return by backed {wheel!r}" 477 | raise RuntimeError(msg) 478 | out, err = result.out, result.err 479 | extract_to = str(metadata_directory) 480 | basename = None 481 | with ZipFile(str(wheel), "r") as zip_file: 482 | for name in zip_file.namelist(): # pragma: no branch 483 | root = Path(name).parts[0] 484 | if root.endswith(".dist-info"): 485 | basename = root 486 | zip_file.extract(name, extract_to) 487 | if basename is None: # pragma: no branch 488 | msg = f"no .dist-info found inside generated wheel {wheel}" 489 | raise RuntimeError(msg) 490 | return metadata_directory / basename, out, err 491 | 492 | @contextmanager 493 | def _wheel_directory(self) -> Iterator[Path]: # noqa: PLR6301 494 | with TemporaryDirectory() as wheel_directory: 495 | yield Path(wheel_directory) 496 | 497 | def _send(self, cmd: str, **kwargs: Any) -> tuple[Any, str, str]: 498 | with NamedTemporaryFile(prefix=f"pep517_{cmd}-") as result_file_marker: 499 | result_file = Path(result_file_marker.name).with_suffix(".json") 500 | msg = json.dumps( 501 | { 502 | "cmd": cmd, 503 | "kwargs": {k: (str(v) if isinstance(v, Path) else v) for k, v in kwargs.items()}, 504 | "result": str(result_file), 505 | }, 506 | ) 507 | with self._send_msg(cmd, result_file, msg) as status: 508 | while not status.done: # pragma: no branch 509 | sleep(0.001) # wait a bit for things to happen 510 | if result_file.exists(): 511 | try: 512 | with result_file.open("rt") as result_handler: 513 | result = json.load(result_handler) 514 | finally: 515 | result_file.unlink() 516 | else: 517 | result = { 518 | "code": 1, 519 | "exc_type": "RuntimeError", 520 | "exc_msg": f"Backend response file {result_file} is missing", 521 | } 522 | out, err = status.out_err() 523 | if "return" in result: 524 | return result["return"], out, err 525 | raise BackendFailed(result, out, err) 526 | 527 | @abstractmethod 528 | @contextmanager 529 | def _send_msg(self, cmd: str, result_file: Path, msg: str) -> Iterator[CmdStatus]: 530 | raise NotImplementedError 531 | -------------------------------------------------------------------------------- /src/pyproject_api/_util.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from shutil import rmtree 4 | from typing import TYPE_CHECKING 5 | 6 | if TYPE_CHECKING: 7 | from pathlib import Path 8 | 9 | 10 | def ensure_empty_dir(path: Path) -> None: 11 | if path.exists(): 12 | if path.is_dir(): 13 | for sub_path in path.iterdir(): 14 | if sub_path.is_dir(): 15 | rmtree(sub_path, ignore_errors=True) 16 | else: 17 | sub_path.unlink() 18 | else: 19 | path.unlink() 20 | path.mkdir() 21 | else: 22 | path.mkdir(parents=True) 23 | 24 | 25 | __all__ = [ 26 | "ensure_empty_dir", 27 | ] 28 | -------------------------------------------------------------------------------- /src/pyproject_api/_via_fresh_subprocess.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import sys 5 | from contextlib import contextmanager 6 | from subprocess import PIPE, Popen # noqa: S404 7 | from threading import Thread 8 | from typing import IO, TYPE_CHECKING, Any, cast 9 | 10 | from ._frontend import CmdStatus, Frontend 11 | 12 | if TYPE_CHECKING: 13 | from collections.abc import Iterator 14 | from pathlib import Path 15 | 16 | from packaging.requirements import Requirement 17 | 18 | 19 | class SubprocessCmdStatus(CmdStatus, Thread): 20 | def __init__(self, process: Popen[str]) -> None: 21 | super().__init__() 22 | self.process = process 23 | self._out_err: tuple[str, str] | None = None 24 | self.start() 25 | 26 | def run(self) -> None: 27 | self._out_err = self.process.communicate() 28 | 29 | @property 30 | def done(self) -> bool: 31 | return self.process.returncode is not None 32 | 33 | def out_err(self) -> tuple[str, str]: 34 | return cast("tuple[str, str]", self._out_err) 35 | 36 | 37 | class SubprocessFrontend(Frontend): 38 | """A frontend that creates fresh subprocess at every call to communicate with the backend.""" 39 | 40 | def __init__( 41 | self, 42 | root: Path, 43 | backend_paths: tuple[Path, ...], 44 | backend_module: str, 45 | backend_obj: str | None, 46 | requires: tuple[Requirement, ...], 47 | ) -> None: 48 | """ 49 | Create a subprocess frontend. 50 | 51 | :param root: the root path to the built project 52 | :param backend_paths: paths that are available on the python path for the backend 53 | :param backend_module: module where the backend is located 54 | :param backend_obj: object within the backend module identifying the backend 55 | :param requires: seed requirements for the backend 56 | """ 57 | super().__init__(root, backend_paths, backend_module, backend_obj, requires, reuse_backend=False) 58 | self.executable = sys.executable 59 | 60 | @contextmanager 61 | def _send_msg(self, cmd: str, result_file: Path, msg: str) -> Iterator[SubprocessCmdStatus]: # noqa: ARG002 62 | env = os.environ.copy() 63 | backend = os.pathsep.join(str(i) for i in self._backend_paths).strip() 64 | if backend: 65 | env["PYTHONPATH"] = backend 66 | process = Popen( 67 | args=[self.executable, *self.backend_args], 68 | stdout=PIPE, 69 | stderr=PIPE, 70 | stdin=PIPE, 71 | universal_newlines=True, 72 | cwd=self._root, 73 | env=env, 74 | ) 75 | cast("IO[str]", process.stdin).write(f"{os.linesep}{msg}{os.linesep}") 76 | yield SubprocessCmdStatus(process) 77 | 78 | def send_cmd(self, cmd: str, **kwargs: Any) -> tuple[Any, str, str]: 79 | """ 80 | Send a command to the backend. 81 | 82 | :param cmd: the command to send 83 | :param kwargs: keyword arguments to the backend 84 | :return: a tuple of: backend response, standard output text, standard error text 85 | """ 86 | return self._send(cmd, **kwargs) 87 | 88 | 89 | __all__ = ("SubprocessFrontend",) 90 | -------------------------------------------------------------------------------- /src/pyproject_api/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/pyproject-api/50675fa8b4856b0da0d2da255c8944a148af5dc6/src/pyproject_api/py.typed -------------------------------------------------------------------------------- /tests/_build_sdist.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/pyproject-api/50675fa8b4856b0da0d2da255c8944a148af5dc6/tests/_build_sdist.py -------------------------------------------------------------------------------- /tests/demo_pkg_inline/build.py: -------------------------------------------------------------------------------- 1 | """ 2 | Please keep this file Python 2.7 compatible. 3 | See https://tox.readthedocs.io/en/rewrite/development.html#code-style-guide 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import os 9 | import sys 10 | import tarfile 11 | from pathlib import Path 12 | from textwrap import dedent 13 | from zipfile import ZipFile 14 | 15 | name = "demo_pkg_inline" 16 | pkg_name = name.replace("_", "-") 17 | 18 | version = "1.0.0" 19 | dist_info = f"{name}-{version}.dist-info" 20 | logic = f"{name}/__init__.py" 21 | metadata_file = f"{dist_info}/METADATA" 22 | wheel = f"{dist_info}/WHEEL" 23 | record = f"{dist_info}/RECORD" 24 | content = { 25 | logic: f"def do():\n print('greetings from {name}')", 26 | } 27 | metadata = { 28 | metadata_file: f""" 29 | Metadata-Version: 2.1 30 | Name: {pkg_name} 31 | Version: {version} 32 | Summary: UNKNOWN 33 | Home-page: UNKNOWN 34 | Author: UNKNOWN 35 | Author-email: UNKNOWN 36 | License: UNKNOWN 37 | Platform: UNKNOWN 38 | 39 | UNKNOWN 40 | """, 41 | wheel: f""" 42 | Wheel-Version: 1.0 43 | Generator: {name}-{version} 44 | Root-Is-Purelib: true 45 | Tag: py{sys.version_info[0]}-none-any 46 | """, 47 | f"{dist_info}/top_level.txt": name, 48 | record: f""" 49 | {name}/__init__.py,, 50 | {dist_info}/METADATA,, 51 | {dist_info}/WHEEL,, 52 | {dist_info}/top_level.txt,, 53 | {dist_info}/RECORD,, 54 | """, 55 | } 56 | 57 | 58 | def build_wheel( 59 | wheel_directory: str, 60 | metadata_directory: str | None = None, 61 | config_settings: dict[str, str] | None = None, # noqa: ARG001 62 | ) -> str: 63 | base_name = f"{name}-{version}-py{sys.version_info[0]}-none-any.whl" 64 | path = Path(wheel_directory) / base_name 65 | with ZipFile(str(path), "w") as zip_file_handler: 66 | for arc_name, data in content.items(): 67 | zip_file_handler.writestr(arc_name, dedent(data).strip()) 68 | if metadata_directory is not None: 69 | for sub_directory, _, filenames in os.walk(metadata_directory): 70 | for filename in filenames: 71 | zip_file_handler.write( 72 | str(Path(metadata_directory) / sub_directory / filename), 73 | str(Path(sub_directory) / filename), 74 | ) 75 | else: 76 | for arc_name, data in metadata.items(): 77 | zip_file_handler.writestr(arc_name, dedent(data).strip()) 78 | print(f"created wheel {path}") # noqa: T201 79 | return base_name 80 | 81 | 82 | def get_requires_for_build_wheel(config_settings: dict[str, str] | None = None) -> list[str]: # noqa: ARG001 83 | return [] # pragma: no cover # only executed in non-host pythons 84 | 85 | 86 | def build_sdist(sdist_directory: str, config_settings: dict[str, str] | None = None) -> str: # noqa: ARG001 87 | result = f"{name}-{version}.tar.gz" 88 | with tarfile.open(str(Path(sdist_directory) / result), "w:gz") as tar: 89 | root = Path(__file__).parent 90 | tar.add(str(root / "build.py"), "build.py") 91 | tar.add(str(root / "pyproject.toml"), "pyproject.toml") 92 | return result 93 | 94 | 95 | def get_requires_for_build_sdist(config_settings: dict[str, str] | None = None) -> list[str]: # noqa: ARG001 96 | return [] # pragma: no cover # only executed in non-host pythons 97 | 98 | 99 | if "HAS_REQUIRES_EDITABLE" in os.environ: 100 | 101 | def get_requires_for_build_editable(config_settings: dict[str, str] | None = None) -> list[str]: # noqa: ARG001 102 | return [1] if "REQUIRES_EDITABLE_BAD_RETURN" in os.environ else ["editables"] # type: ignore[list-item] 103 | 104 | 105 | if "HAS_PREPARE_EDITABLE" in os.environ: 106 | 107 | def prepare_metadata_for_build_editable( 108 | metadata_directory: str, 109 | config_settings: dict[str, str] | None = None, # noqa: ARG001 110 | ) -> str: 111 | dest = Path(metadata_directory) / dist_info 112 | dest.mkdir(parents=True) 113 | for arc_name, data in metadata.items(): 114 | (dest.parent / arc_name).write_text(dedent(data).strip()) 115 | print(f"created metadata {dest}") # noqa: T201 116 | if "PREPARE_EDITABLE_BAD" in os.environ: 117 | return 1 # type: ignore[return-value] # checking bad type on purpose 118 | return dist_info 119 | 120 | 121 | def build_editable( 122 | wheel_directory: str, 123 | metadata_directory: str | None = None, 124 | config_settings: dict[str, str] | None = None, 125 | ) -> str: 126 | if "BUILD_EDITABLE_BAD" in os.environ: 127 | return 1 # type: ignore[return-value] # checking bad type on purpose 128 | return build_wheel(wheel_directory, metadata_directory, config_settings) 129 | -------------------------------------------------------------------------------- /tests/demo_pkg_inline/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] # noqa: D100 2 | build-backend = "build" 3 | requires = [ 4 | ] 5 | backend-path = [ 6 | ".", 7 | ] 8 | 9 | [tool.black] 10 | line-length = 120 11 | -------------------------------------------------------------------------------- /tests/test_backend.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import os 5 | from typing import TYPE_CHECKING, Any 6 | 7 | import pytest 8 | 9 | from pyproject_api._backend import BackendProxy, read_line, run 10 | 11 | if TYPE_CHECKING: 12 | from pathlib import Path 13 | 14 | import pytest_mock 15 | 16 | 17 | def test_invalid_module(capsys: pytest.CaptureFixture[str]) -> None: 18 | with pytest.raises(ImportError): 19 | run([str(False), "an.invalid.module"]) 20 | 21 | captured = capsys.readouterr() 22 | assert "failed to start backend" in captured.err 23 | 24 | 25 | def test_invalid_request(mocker: pytest_mock.MockerFixture, capsys: pytest.CaptureFixture[str]) -> None: 26 | """Validate behavior when an invalid request is issued.""" 27 | command = "invalid json" 28 | 29 | backend_proxy = mocker.MagicMock(spec=BackendProxy) 30 | backend_proxy.return_value = "dummy_result" 31 | backend_proxy.__str__.return_value = "FakeBackendProxy" 32 | mocker.patch("pyproject_api._backend.BackendProxy", return_value=backend_proxy) 33 | mocker.patch("pyproject_api._backend.read_line", return_value=bytearray(command, "utf-8")) 34 | 35 | ret = run([str(False), "a.dummy.module"]) 36 | 37 | assert ret == 0 38 | captured = capsys.readouterr() 39 | assert "started backend " in captured.out 40 | assert "Backend: incorrect request to backend: " in captured.err 41 | 42 | 43 | def test_exception(mocker: pytest_mock.MockerFixture, capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None: 44 | """Ensure an exception in the backend is not bubbled up.""" 45 | result = str(tmp_path / "result") 46 | command = json.dumps({"cmd": "dummy_command", "kwargs": {"foo": "bar"}, "result": result}) 47 | 48 | backend_proxy = mocker.MagicMock(spec=BackendProxy) 49 | backend_proxy.side_effect = SystemExit(1) 50 | backend_proxy.__str__.return_value = "FakeBackendProxy" 51 | mocker.patch("pyproject_api._backend.BackendProxy", return_value=backend_proxy) 52 | mocker.patch("pyproject_api._backend.read_line", return_value=bytearray(command, "utf-8")) 53 | 54 | ret = run([str(False), "a.dummy.module"]) 55 | 56 | # We still return 0 and write a result file. The exception should *not* bubble up 57 | assert ret == 0 58 | captured = capsys.readouterr() 59 | assert "started backend FakeBackendProxy" in captured.out 60 | assert "Backend: run command dummy_command with args {'foo': 'bar'}" in captured.out 61 | assert "Backend: Wrote response " in captured.out 62 | assert "SystemExit: 1" in captured.err 63 | 64 | 65 | def test_valid_request(mocker: pytest_mock.MockerFixture, capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None: 66 | """Validate the "success" path.""" 67 | result = str(tmp_path / "result") 68 | command = json.dumps({"cmd": "dummy_command", "kwargs": {"foo": "bar"}, "result": result}) 69 | 70 | backend_proxy = mocker.MagicMock(spec=BackendProxy) 71 | backend_proxy.return_value = "dummy-result" 72 | backend_proxy.__str__.return_value = "FakeBackendProxy" 73 | mocker.patch("pyproject_api._backend.BackendProxy", return_value=backend_proxy) 74 | mocker.patch("pyproject_api._backend.read_line", return_value=bytearray(command, "utf-8")) 75 | 76 | ret = run([str(False), "a.dummy.module"]) 77 | 78 | assert ret == 0 79 | captured = capsys.readouterr() 80 | assert "started backend FakeBackendProxy" in captured.out 81 | assert "Backend: run command dummy_command with args {'foo': 'bar'}" in captured.out 82 | assert "Backend: Wrote response " in captured.out 83 | assert not captured.err 84 | 85 | 86 | def test_reuse_process(mocker: pytest_mock.MockerFixture, capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None: 87 | """Validate behavior when reusing the backend proxy process. 88 | 89 | There are a couple of things we'd like to check here: 90 | 91 | - Ensure we can actually reuse the process. 92 | - Ensure an exception in a call to the backend does not affect subsequent calls. 93 | - Ensure we can exit safely by calling the '_exit' command. 94 | """ 95 | results = [ 96 | str(tmp_path / "result_a"), 97 | str(tmp_path / "result_b"), 98 | str(tmp_path / "result_c"), 99 | str(tmp_path / "result_d"), 100 | ] 101 | commands = [ 102 | json.dumps({"cmd": "dummy_command_a", "kwargs": {"foo": "bar"}, "result": results[0]}), 103 | json.dumps({"cmd": "dummy_command_b", "kwargs": {"baz": "qux"}, "result": results[1]}), 104 | json.dumps({"cmd": "dummy_command_c", "kwargs": {"win": "wow"}, "result": results[2]}), 105 | json.dumps({"cmd": "_exit", "kwargs": {}, "result": results[3]}), 106 | ] 107 | 108 | def fake_backend(name: str, *args: Any, **kwargs: Any) -> Any: # noqa: ARG001 109 | if name == "dummy_command_b": 110 | raise SystemExit(2) 111 | 112 | return "dummy-result" 113 | 114 | backend_proxy = mocker.MagicMock(spec=BackendProxy) 115 | backend_proxy.side_effect = fake_backend 116 | backend_proxy.__str__.return_value = "FakeBackendProxy" 117 | mocker.patch("pyproject_api._backend.BackendProxy", return_value=backend_proxy) 118 | mocker.patch("pyproject_api._backend.read_line", side_effect=[bytearray(x, "utf-8") for x in commands]) 119 | 120 | ret = run([str(True), "a.dummy.module"]) 121 | 122 | # We still return 0 and write a result file. The exception should *not* bubble up and all commands should execute. 123 | # It is the responsibility of the caller to handle errors. 124 | assert ret == 0 125 | captured = capsys.readouterr() 126 | assert "started backend FakeBackendProxy" in captured.out 127 | assert "Backend: run command dummy_command_a with args {'foo': 'bar'}" in captured.out 128 | assert "Backend: run command dummy_command_b with args {'baz': 'qux'}" in captured.out 129 | assert "Backend: run command dummy_command_c with args {'win': 'wow'}" in captured.out 130 | assert "SystemExit: 2" in captured.err 131 | 132 | 133 | def test_read_line_success() -> None: 134 | r, w = os.pipe() 135 | try: 136 | line_in = b"this is a line\r\n" 137 | os.write(w, line_in) 138 | line_out = read_line(fd=r) 139 | assert line_out == bytearray(b"this is a line") 140 | finally: 141 | os.close(r) 142 | os.close(w) 143 | 144 | 145 | def test_read_line_eof_before_newline() -> None: 146 | r, w = os.pipe() 147 | try: 148 | line_in = b"this is a line" 149 | os.write(w, line_in) 150 | os.close(w) 151 | line_out = read_line(fd=r) 152 | assert line_out == bytearray(b"this is a line") 153 | finally: 154 | os.close(r) 155 | 156 | 157 | def test_read_line_eof_at_the_beginning() -> None: 158 | r, w = os.pipe() 159 | try: 160 | os.close(w) 161 | with pytest.raises(EOFError): 162 | read_line(fd=r) 163 | finally: 164 | os.close(r) 165 | -------------------------------------------------------------------------------- /tests/test_frontend.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | from textwrap import dedent 5 | from typing import Callable, Literal 6 | 7 | import pytest 8 | from packaging.requirements import Requirement 9 | 10 | from pyproject_api._frontend import BackendFailed 11 | from pyproject_api._via_fresh_subprocess import SubprocessFrontend 12 | 13 | 14 | @pytest.fixture 15 | def local_builder(tmp_path: Path) -> Callable[[str], Path]: 16 | def _f(content: str) -> Path: 17 | toml = '[build-system]\nrequires=[]\nbuild-backend = "build_tester"\nbackend-path=["."]' 18 | (tmp_path / "pyproject.toml").write_text(toml) 19 | (tmp_path / "build_tester.py").write_text(dedent(content)) 20 | return tmp_path 21 | 22 | return _f 23 | 24 | 25 | def test_missing_backend(local_builder: Callable[[str], Path]) -> None: 26 | tmp_path = local_builder("") 27 | toml = tmp_path / "pyproject.toml" 28 | toml.write_text('[build-system]\nrequires=[]\nbuild-backend = "build_tester"') 29 | frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(tmp_path)[:-1]) 30 | with pytest.raises(BackendFailed) as context: 31 | frontend.build_wheel(tmp_path / "wheel") 32 | exc = context.value 33 | assert exc.exc_type == "RuntimeError" 34 | assert exc.code == 1 35 | assert "failed to start backend" in exc.err 36 | assert "ModuleNotFoundError: No module named " in exc.err 37 | 38 | 39 | @pytest.mark.parametrize("cmd", ["build_wheel", "build_sdist"]) 40 | def test_missing_required_cmd(cmd: str, local_builder: Callable[[str], Path]) -> None: 41 | tmp_path = local_builder("") 42 | frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(tmp_path)[:-1]) 43 | 44 | with pytest.raises(BackendFailed) as context: 45 | getattr(frontend, cmd)(tmp_path) 46 | exc = context.value 47 | assert f"has no attribute '{cmd}'" in exc.exc_msg 48 | assert exc.exc_type == "MissingCommand" 49 | 50 | 51 | def test_empty_pyproject(tmp_path: Path) -> None: 52 | (tmp_path / "pyproject.toml").write_text("[build-system]") 53 | root, backend_paths, backend_module, backend_obj, requires, _ = SubprocessFrontend.create_args_from_folder(tmp_path) 54 | assert root == tmp_path 55 | assert backend_paths == () 56 | assert backend_module == "setuptools.build_meta" 57 | assert backend_obj == "__legacy__" 58 | for left, right in zip(requires, (Requirement("setuptools>=40.8.0"), Requirement("wheel"))): 59 | assert isinstance(left, Requirement) 60 | assert str(left) == str(right) 61 | 62 | 63 | @pytest.fixture(scope="session") 64 | def demo_pkg_inline() -> Path: 65 | return Path(__file__).absolute().parent / "demo_pkg_inline" 66 | 67 | 68 | def test_backend_no_prepare_wheel(tmp_path: Path, demo_pkg_inline: Path) -> None: 69 | frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(demo_pkg_inline)[:-1]) 70 | result = frontend.prepare_metadata_for_build_wheel(tmp_path) 71 | assert result is None 72 | 73 | 74 | def test_backend_build_sdist_demo_pkg_inline(tmp_path: Path, demo_pkg_inline: Path) -> None: 75 | frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(demo_pkg_inline)[:-1]) 76 | result = frontend.build_sdist(sdist_directory=tmp_path) 77 | assert result.sdist == tmp_path / "demo_pkg_inline-1.0.0.tar.gz" 78 | 79 | 80 | def test_backend_obj(tmp_path: Path) -> None: 81 | toml = """ 82 | [build-system] 83 | requires=[] 84 | build-backend = "build.api:backend:" 85 | backend-path=["."] 86 | """ 87 | api = """ 88 | class A: 89 | def get_requires_for_build_sdist(self, config_settings=None): 90 | return ["a"] 91 | 92 | backend = A() 93 | """ 94 | (tmp_path / "pyproject.toml").write_text(dedent(toml)) 95 | build = tmp_path / "build" 96 | build.mkdir() 97 | (build / "__init__.py").write_text("") 98 | (build / "api.py").write_text(dedent(api)) 99 | frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(tmp_path)[:-1]) 100 | result = frontend.get_requires_for_build_sdist() 101 | for left, right in zip(result.requires, (Requirement("a"),)): 102 | assert isinstance(left, Requirement) 103 | assert str(left) == str(right) 104 | 105 | 106 | @pytest.mark.parametrize("of_type", ["wheel", "sdist"]) 107 | def test_get_requires_for_build_missing(of_type: str, local_builder: Callable[[str], Path]) -> None: 108 | tmp_path = local_builder("") 109 | frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(tmp_path)[:-1]) 110 | result = getattr(frontend, f"get_requires_for_build_{of_type}")() 111 | assert result.requires == () 112 | 113 | 114 | @pytest.mark.parametrize("of_type", ["sdist", "wheel"]) 115 | def test_bad_return_type_get_requires_for_build(of_type: str, local_builder: Callable[[str], Path]) -> None: 116 | tmp_path = local_builder(f"def get_requires_for_build_{of_type}(config_settings=None): return 1") 117 | frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(tmp_path)[:-1]) 118 | 119 | with pytest.raises(BackendFailed) as context: 120 | getattr(frontend, f"get_requires_for_build_{of_type}")() 121 | 122 | exc = context.value 123 | msg = f"'get_requires_for_build_{of_type}' on 'build_tester' returned 1 but expected type 'list of string'" 124 | assert exc.exc_msg == msg 125 | assert exc.exc_type == "TypeError" 126 | 127 | 128 | def test_bad_return_type_build_sdist(local_builder: Callable[[str], Path]) -> None: 129 | tmp_path = local_builder("def build_sdist(sdist_directory, config_settings=None): return 1") 130 | frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(tmp_path)[:-1]) 131 | 132 | with pytest.raises(BackendFailed) as context: 133 | frontend.build_sdist(tmp_path) 134 | 135 | exc = context.value 136 | assert exc.exc_msg == f"'build_sdist' on 'build_tester' returned 1 but expected type {str!r}" 137 | assert exc.exc_type == "TypeError" 138 | 139 | 140 | def test_bad_return_type_build_wheel(local_builder: Callable[[str], Path]) -> None: 141 | txt = "def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): return 1" 142 | tmp_path = local_builder(txt) 143 | frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(tmp_path)[:-1]) 144 | 145 | with pytest.raises(BackendFailed) as context: 146 | frontend.build_wheel(tmp_path) 147 | 148 | exc = context.value 149 | assert exc.exc_msg == f"'build_wheel' on 'build_tester' returned 1 but expected type {str!r}" 150 | assert exc.exc_type == "TypeError" 151 | 152 | 153 | def test_bad_return_type_prepare_metadata_for_build_wheel(local_builder: Callable[[str], Path]) -> None: 154 | tmp_path = local_builder("def prepare_metadata_for_build_wheel(metadata_directory, config_settings=None): return 1") 155 | frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(tmp_path)[:-1]) 156 | 157 | with pytest.raises(BackendFailed) as context: 158 | frontend.prepare_metadata_for_build_wheel(tmp_path / "meta") 159 | 160 | exc = context.value 161 | assert exc.exc_type == "TypeError" 162 | assert exc.exc_msg == f"'prepare_metadata_for_build_wheel' on 'build_tester' returned 1 but expected type {str!r}" 163 | 164 | 165 | def test_prepare_metadata_for_build_wheel_meta_is_root(local_builder: Callable[[str], Path]) -> None: 166 | tmp_path = local_builder("def prepare_metadata_for_build_wheel(metadata_directory, config_settings=None): return 1") 167 | frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(tmp_path)[:-1]) 168 | 169 | with pytest.raises(RuntimeError) as context: 170 | frontend.prepare_metadata_for_build_wheel(tmp_path) 171 | 172 | assert str(context.value) == f"the project root and the metadata directory can't be the same {tmp_path}" 173 | 174 | 175 | def test_no_wheel_prepare_metadata_for_build_wheel(local_builder: Callable[[str], Path]) -> None: 176 | txt = "def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): return 'out'" 177 | tmp_path = local_builder(txt) 178 | frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(tmp_path)[:-1]) 179 | 180 | with pytest.raises(RuntimeError, match=r"missing wheel file return by backed *"): 181 | frontend.metadata_from_built(tmp_path, "wheel") 182 | 183 | 184 | @pytest.mark.parametrize("target", ["wheel", "editable"]) 185 | def test_metadata_from_built_wheel( 186 | demo_pkg_inline: Path, 187 | tmp_path: Path, 188 | target: Literal["wheel", "editable"], 189 | monkeypatch: pytest.MonkeyPatch, 190 | ) -> None: 191 | frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(demo_pkg_inline)[:-1]) 192 | monkeypatch.chdir(tmp_path) 193 | path, out, err = frontend.metadata_from_built(tmp_path, target) 194 | assert path == tmp_path / "demo_pkg_inline-1.0.0.dist-info" 195 | assert {p.name for p in path.iterdir()} == {"top_level.txt", "WHEEL", "RECORD", "METADATA"} 196 | assert f" build_{target}" in out 197 | assert not err 198 | 199 | 200 | def test_bad_wheel_metadata_from_built_wheel(local_builder: Callable[[str], Path]) -> None: 201 | txt = """ 202 | import sys 203 | from pathlib import Path 204 | from zipfile import ZipFile 205 | 206 | def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): 207 | path = Path(wheel_directory) / "out" 208 | with ZipFile(str(path), "w") as zip_file_handler: 209 | pass 210 | print(f"created wheel {path}") 211 | return path.name 212 | """ 213 | tmp_path = local_builder(txt) 214 | frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(tmp_path)[:-1]) 215 | 216 | with pytest.raises(RuntimeError, match=r"no .dist-info found inside generated wheel*"): 217 | frontend.metadata_from_built(tmp_path, "wheel") 218 | 219 | 220 | def test_create_no_pyproject(tmp_path: Path) -> None: 221 | result = SubprocessFrontend.create_args_from_folder(tmp_path) 222 | assert len(result) == 6 223 | assert result[0] == tmp_path 224 | assert result[1] == () 225 | assert result[2] == "setuptools.build_meta" 226 | assert result[3] == "__legacy__" 227 | assert all(isinstance(i, Requirement) for i in result[4]) 228 | assert [str(i) for i in result[4]] == ["setuptools>=40.8.0"] 229 | assert result[5] is True 230 | 231 | 232 | def test_backend_get_requires_for_build_editable(demo_pkg_inline: Path, monkeypatch: pytest.MonkeyPatch) -> None: 233 | monkeypatch.setenv("HAS_REQUIRES_EDITABLE", "1") 234 | monkeypatch.delenv("REQUIRES_EDITABLE_BAD_RETURN", raising=False) 235 | frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(demo_pkg_inline)[:-1]) 236 | result = frontend.get_requires_for_build_editable() 237 | assert [str(i) for i in result.requires] == ["editables"] 238 | assert isinstance(result.requires[0], Requirement) 239 | assert " get_requires_for_build_editable " in result.out 240 | assert not result.err 241 | 242 | 243 | def test_backend_get_requires_for_build_editable_miss(demo_pkg_inline: Path, monkeypatch: pytest.MonkeyPatch) -> None: 244 | monkeypatch.delenv("HAS_REQUIRES_EDITABLE", raising=False) 245 | frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(demo_pkg_inline)[:-1]) 246 | result = frontend.get_requires_for_build_editable() 247 | assert not result.requires 248 | assert not result.out 249 | assert not result.err 250 | 251 | 252 | def test_backend_get_requires_for_build_editable_bad(demo_pkg_inline: Path, monkeypatch: pytest.MonkeyPatch) -> None: 253 | monkeypatch.setenv("HAS_REQUIRES_EDITABLE", "1") 254 | monkeypatch.setenv("REQUIRES_EDITABLE_BAD_RETURN", "1") 255 | frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(demo_pkg_inline)[:-1]) 256 | with pytest.raises(BackendFailed) as context: 257 | frontend.get_requires_for_build_editable() 258 | exc = context.value 259 | assert exc.code is None 260 | assert not exc.err 261 | assert " get_requires_for_build_editable " in exc.out 262 | assert not exc.args 263 | assert exc.exc_type == "TypeError" 264 | assert exc.exc_msg == "'get_requires_for_build_editable' on 'build' returned [1] but expected type 'list of string'" 265 | 266 | 267 | def test_backend_prepare_editable(tmp_path: Path, demo_pkg_inline: Path, monkeypatch: pytest.MonkeyPatch) -> None: 268 | monkeypatch.setenv("HAS_PREPARE_EDITABLE", "1") 269 | monkeypatch.delenv("PREPARE_EDITABLE_BAD", raising=False) 270 | frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(demo_pkg_inline)[:-1]) 271 | result = frontend.prepare_metadata_for_build_editable(tmp_path) 272 | assert result is not None 273 | assert result.metadata.name == "demo_pkg_inline-1.0.0.dist-info" 274 | assert " prepare_metadata_for_build_editable " in result.out 275 | assert " build_editable " not in result.out 276 | assert not result.err 277 | 278 | 279 | def test_backend_prepare_editable_miss(tmp_path: Path, demo_pkg_inline: Path, monkeypatch: pytest.MonkeyPatch) -> None: 280 | monkeypatch.delenv("HAS_PREPARE_EDITABLE", raising=False) 281 | monkeypatch.delenv("BUILD_EDITABLE_BAD", raising=False) 282 | frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(demo_pkg_inline)[:-1]) 283 | result = frontend.prepare_metadata_for_build_editable(tmp_path) 284 | assert result is None 285 | 286 | 287 | def test_backend_prepare_editable_bad(tmp_path: Path, demo_pkg_inline: Path, monkeypatch: pytest.MonkeyPatch) -> None: 288 | monkeypatch.setenv("HAS_PREPARE_EDITABLE", "1") 289 | monkeypatch.setenv("PREPARE_EDITABLE_BAD", "1") 290 | frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(demo_pkg_inline)[:-1]) 291 | with pytest.raises(BackendFailed) as context: 292 | frontend.prepare_metadata_for_build_editable(tmp_path) 293 | exc = context.value 294 | assert exc.code is None 295 | assert not exc.err 296 | assert " prepare_metadata_for_build_editable " in exc.out 297 | assert not exc.args 298 | assert exc.exc_type == "TypeError" 299 | assert exc.exc_msg == "'prepare_metadata_for_build_wheel' on 'build' returned 1 but expected type " 300 | 301 | 302 | def test_backend_build_editable(tmp_path: Path, demo_pkg_inline: Path, monkeypatch: pytest.MonkeyPatch) -> None: 303 | monkeypatch.delenv("BUILD_EDITABLE_BAD", raising=False) 304 | monkeypatch.setenv("HAS_PREPARE_EDITABLE", "1") 305 | frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(demo_pkg_inline)[:-1]) 306 | meta = tmp_path / "meta" 307 | res = frontend.prepare_metadata_for_build_editable(meta) 308 | assert res is not None 309 | metadata = res.metadata 310 | assert metadata is not None 311 | assert metadata.name == "demo_pkg_inline-1.0.0.dist-info" 312 | result = frontend.build_editable(tmp_path, metadata_directory=meta) 313 | assert result.wheel.name == "demo_pkg_inline-1.0.0-py3-none-any.whl" 314 | assert " build_editable " in result.out 315 | assert not result.err 316 | 317 | 318 | def test_backend_build_wheel(tmp_path: Path, demo_pkg_inline: Path) -> None: 319 | frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(demo_pkg_inline)[:-1]) 320 | result = frontend.build_wheel(tmp_path) 321 | assert result.wheel.name == "demo_pkg_inline-1.0.0-py3-none-any.whl" 322 | assert " build_wheel " in result.out 323 | assert not result.err 324 | 325 | 326 | def test_backend_build_editable_bad(tmp_path: Path, demo_pkg_inline: Path, monkeypatch: pytest.MonkeyPatch) -> None: 327 | monkeypatch.setenv("BUILD_EDITABLE_BAD", "1") 328 | frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(demo_pkg_inline)[:-1]) 329 | with pytest.raises(BackendFailed) as context: 330 | frontend.build_editable(tmp_path) 331 | exc = context.value 332 | assert exc.code is None 333 | assert not exc.err 334 | assert " build_editable " in exc.out 335 | assert not exc.args 336 | assert exc.exc_type == "TypeError" 337 | assert exc.exc_msg == "'build_editable' on 'build' returned 1 but expected type " 338 | -------------------------------------------------------------------------------- /tests/test_frontend_setuptools.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from contextlib import contextmanager 5 | from stat import S_IWGRP, S_IWOTH, S_IWUSR 6 | from typing import TYPE_CHECKING, NamedTuple 7 | 8 | import pytest 9 | 10 | from pyproject_api._frontend import BackendFailed 11 | from pyproject_api._via_fresh_subprocess import SubprocessFrontend 12 | 13 | if TYPE_CHECKING: 14 | from collections.abc import Iterator 15 | from pathlib import Path 16 | 17 | from _pytest.tmpdir import TempPathFactory 18 | from pytest_mock import MockerFixture 19 | 20 | from importlib.metadata import Distribution, EntryPoint 21 | 22 | 23 | @pytest.fixture(scope="session") 24 | def frontend_setuptools(tmp_path_factory: TempPathFactory) -> SubprocessFrontend: 25 | prj = tmp_path_factory.mktemp("proj") 26 | (prj / "pyproject.toml").write_text( 27 | '[build-system]\nrequires=["setuptools","wheel"]\nbuild-backend = "setuptools.build_meta"', 28 | ) 29 | cfg = """ 30 | [metadata] 31 | name = demo 32 | version = 1.0 33 | 34 | [options] 35 | packages = demo 36 | install_requires = 37 | requests>2 38 | magic>3 39 | 40 | [options.entry_points] 41 | console_scripts = 42 | demo_exe = demo:a 43 | """ 44 | (prj / "setup.cfg").write_text(cfg) 45 | (prj / "setup.py").write_text("from setuptools import setup; setup()") 46 | demo = prj / "demo" 47 | demo.mkdir() 48 | (demo / "__init__.py").write_text("def a(): print('ok')") 49 | args = SubprocessFrontend.create_args_from_folder(prj) 50 | return SubprocessFrontend(*args[:-1]) 51 | 52 | 53 | def test_setuptools_get_requires_for_build_sdist(frontend_setuptools: SubprocessFrontend) -> None: 54 | result = frontend_setuptools.get_requires_for_build_sdist() 55 | assert result.requires == () 56 | assert isinstance(result.out, str) 57 | assert isinstance(result.err, str) 58 | 59 | 60 | def test_setuptools_get_requires_for_build_wheel(frontend_setuptools: SubprocessFrontend) -> None: 61 | result = frontend_setuptools.get_requires_for_build_wheel() 62 | assert not result.requires 63 | assert isinstance(result.out, str) 64 | assert isinstance(result.err, str) 65 | 66 | 67 | def test_setuptools_prepare_metadata_for_build_wheel(frontend_setuptools: SubprocessFrontend, tmp_path: Path) -> None: 68 | meta = tmp_path / "meta" 69 | result = frontend_setuptools.prepare_metadata_for_build_wheel(metadata_directory=meta) 70 | assert result is not None 71 | dist = Distribution.at(str(result.metadata)) 72 | assert list(dist.entry_points) == [EntryPoint(name="demo_exe", value="demo:a", group="console_scripts")] 73 | assert dist.version == "1.0" 74 | assert dist.metadata["Name"] == "demo" 75 | values = [v for k, v in dist.metadata.items() if k == "Requires-Dist"] # type: ignore[attr-defined] 76 | # ignore because "PackageMetadata" has no attribute "items" 77 | expected = ["magic>3", "requests>2"] if sys.version_info[0:2] > (3, 8) else ["magic >3", "requests >2"] 78 | assert sorted(values) == expected 79 | assert isinstance(result.out, str) 80 | assert isinstance(result.err, str) 81 | 82 | # call it again regenerates it because frontend always deletes earlier content 83 | before = result.metadata.stat().st_mtime 84 | result = frontend_setuptools.prepare_metadata_for_build_wheel(metadata_directory=meta) 85 | assert result is not None 86 | after = result.metadata.stat().st_mtime 87 | assert after > before 88 | 89 | 90 | def test_setuptools_build_sdist(frontend_setuptools: SubprocessFrontend, tmp_path: Path) -> None: 91 | result = frontend_setuptools.build_sdist(tmp_path) 92 | sdist = result.sdist 93 | assert sdist.exists() 94 | assert sdist.is_file() 95 | assert sdist.name == "demo-1.0.tar.gz" 96 | assert isinstance(result.out, str) 97 | assert isinstance(result.err, str) 98 | 99 | 100 | def test_setuptools_build_wheel(frontend_setuptools: SubprocessFrontend, tmp_path: Path) -> None: 101 | result = frontend_setuptools.build_wheel(tmp_path) 102 | wheel = result.wheel 103 | assert wheel.exists() 104 | assert wheel.is_file() 105 | assert wheel.name == "demo-1.0-py3-none-any.whl" 106 | assert isinstance(result.out, str) 107 | assert isinstance(result.err, str) 108 | 109 | 110 | def test_setuptools_exit(frontend_setuptools: SubprocessFrontend) -> None: 111 | result, out, err = frontend_setuptools.send_cmd("_exit") 112 | assert isinstance(out, str) 113 | assert isinstance(err, str) 114 | assert result == 0 115 | 116 | 117 | def test_setuptools_missing_command(frontend_setuptools: SubprocessFrontend) -> None: 118 | with pytest.raises(BackendFailed): 119 | frontend_setuptools.send_cmd("missing_command") 120 | 121 | 122 | def test_setuptools_exception(frontend_setuptools: SubprocessFrontend) -> None: 123 | with pytest.raises(BackendFailed) as context: 124 | frontend_setuptools.send_cmd("build_wheel") 125 | assert isinstance(context.value.out, str) 126 | assert isinstance(context.value.err, str) 127 | assert context.value.exc_type == "TypeError" 128 | prefix = "_BuildMetaBackend." if sys.version_info >= (3, 10) else "" 129 | msg = f"{prefix}build_wheel() missing 1 required positional argument: 'wheel_directory'" 130 | assert context.value.exc_msg == msg 131 | assert context.value.code == 1 132 | assert context.value.args == () 133 | assert repr(context.value) 134 | assert str(context.value) 135 | assert repr(context.value) != str(context.value) 136 | 137 | 138 | def test_bad_message(frontend_setuptools: SubprocessFrontend, tmp_path: Path) -> None: 139 | with frontend_setuptools._send_msg("bad_cmd", tmp_path / "a", "{{") as status: # noqa: SLF001 140 | while not status.done: # pragma: no branch 141 | pass 142 | out, err = status.out_err() 143 | assert out 144 | assert "Backend: incorrect request to backend: bytearray(b'{{')" in err 145 | 146 | 147 | class _Result(NamedTuple): 148 | name: str 149 | 150 | 151 | def test_result_missing(frontend_setuptools: SubprocessFrontend, tmp_path: Path, mocker: MockerFixture) -> None: 152 | @contextmanager 153 | def named_temporary_file(prefix: str) -> Iterator[_Result]: 154 | write = S_IWUSR | S_IWGRP | S_IWOTH 155 | base = tmp_path / prefix 156 | result = base.with_suffix(".json") 157 | result.write_text("") 158 | result.chmod(result.stat().st_mode & ~write) # force json write to fail due to R/O 159 | patch = mocker.patch("pyproject_api._frontend.Path.exists", return_value=False) # make it missing 160 | try: 161 | yield _Result(str(base)) 162 | finally: 163 | patch.stop() 164 | result.chmod(result.stat().st_mode | write) # cleanup 165 | result.unlink() 166 | 167 | mocker.patch("pyproject_api._frontend.NamedTemporaryFile", named_temporary_file) 168 | with pytest.raises(BackendFailed) as context: 169 | frontend_setuptools.send_cmd("_exit") 170 | exc = context.value 171 | assert exc.exc_msg == f"Backend response file {tmp_path / 'pep517__exit-.json'} is missing" 172 | assert exc.exc_type == "RuntimeError" 173 | assert exc.code == 1 174 | assert "Traceback" in exc.err 175 | assert "PermissionError" in exc.err 176 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | from typing import TYPE_CHECKING 5 | 6 | import pytest 7 | 8 | import pyproject_api.__main__ 9 | from pyproject_api._frontend import EditableResult, SdistResult, WheelResult 10 | 11 | if TYPE_CHECKING: 12 | import pytest_mock 13 | 14 | 15 | @pytest.mark.parametrize( 16 | ("cli_args", "srcdir", "outdir", "hooks"), 17 | [ 18 | ( 19 | [], 20 | Path.cwd(), 21 | Path.cwd() / "dist", 22 | ["build_sdist", "build_wheel"], 23 | ), 24 | ( 25 | ["src"], 26 | Path("src"), 27 | Path("src") / "dist", 28 | ["build_sdist", "build_wheel"], 29 | ), 30 | ( 31 | ["-o", "out"], 32 | Path.cwd(), 33 | Path("out"), 34 | ["build_sdist", "build_wheel"], 35 | ), 36 | ( 37 | ["-s"], 38 | Path.cwd(), 39 | Path.cwd() / "dist", 40 | ["build_sdist"], 41 | ), 42 | ( 43 | ["-w"], 44 | Path.cwd(), 45 | Path.cwd() / "dist", 46 | ["build_wheel"], 47 | ), 48 | ( 49 | ["-e"], 50 | Path.cwd(), 51 | Path.cwd() / "dist", 52 | ["build_editable"], 53 | ), 54 | ( 55 | ["-s", "-w"], 56 | Path.cwd(), 57 | Path.cwd() / "dist", 58 | ["build_sdist", "build_wheel"], 59 | ), 60 | ], 61 | ) 62 | def test_parse_args( 63 | mocker: pytest_mock.MockerFixture, 64 | capsys: pytest.CaptureFixture[str], 65 | cli_args: list[str], 66 | srcdir: Path, 67 | outdir: Path, 68 | hooks: list[str], 69 | ) -> None: 70 | subprocess_frontend = mocker.patch("pyproject_api.__main__.SubprocessFrontend", autospec=True) 71 | subprocess_frontend.create_args_from_folder.return_value = (srcdir, (), "foo.bar", "baz", (), True) 72 | subprocess_frontend.return_value.build_sdist.return_value = SdistResult( 73 | sdist=outdir / "foo.whl", 74 | out="sdist out", 75 | err="sdist err", 76 | ) 77 | subprocess_frontend.return_value.build_wheel.return_value = WheelResult( 78 | wheel=outdir / "foo.whl", 79 | out="wheel out", 80 | err="wheel err", 81 | ) 82 | subprocess_frontend.return_value.build_editable.return_value = EditableResult( 83 | wheel=outdir / "foo.whl", 84 | out="editable wheel out", 85 | err="editable wheel err", 86 | ) 87 | 88 | pyproject_api.__main__.main(cli_args) 89 | 90 | subprocess_frontend.create_args_from_folder.assert_called_once_with(srcdir) 91 | captured = capsys.readouterr() 92 | 93 | if "build_sdist" in hooks: 94 | assert "Building sdist..." in captured.out 95 | subprocess_frontend.return_value.build_sdist.assert_called_once_with(outdir) 96 | assert "sdist out" in captured.out 97 | assert "sdist err" in captured.err 98 | 99 | if "build_wheel" in hooks: 100 | assert "Building wheel..." in captured.out 101 | subprocess_frontend.return_value.build_wheel.assert_called_once_with(outdir) 102 | assert "wheel out" in captured.out 103 | assert "wheel err" in captured.err 104 | 105 | if "build_editable" in hooks: 106 | assert "Building editable wheel..." in captured.out 107 | subprocess_frontend.return_value.build_editable.assert_called_once_with(outdir) 108 | assert "editable wheel out" in captured.out 109 | assert "editable wheel err" in captured.err 110 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from pyproject_api._util import ensure_empty_dir 6 | 7 | if TYPE_CHECKING: 8 | from pathlib import Path 9 | 10 | 11 | def test_ensure_empty_dir_on_empty(tmp_path: Path) -> None: 12 | ensure_empty_dir(tmp_path) 13 | assert list(tmp_path.iterdir()) == [] 14 | 15 | 16 | def test_ensure_empty_dir_on_path_missing(tmp_path: Path) -> None: 17 | path = tmp_path / "a" 18 | ensure_empty_dir(path) 19 | assert list(path.iterdir()) == [] 20 | 21 | 22 | def test_ensure_empty_dir_on_path_file(tmp_path: Path) -> None: 23 | path = tmp_path / "a" 24 | path.write_text("") 25 | ensure_empty_dir(path) 26 | assert list(path.iterdir()) == [] 27 | 28 | 29 | def test_ensure_empty_dir_on_path_folder(tmp_path: Path) -> None: 30 | """ 31 | ├──  a 32 | │ ├──  a 33 | │ └──  b 34 | │ └──  c 35 | └──  d 36 | """ 37 | path = tmp_path / "a" 38 | path.mkdir() 39 | (path / "a").write_text("") 40 | sub_dir = path / "b" 41 | sub_dir.mkdir() 42 | (sub_dir / "c").write_text("") 43 | (tmp_path / "d").write_text("") 44 | ensure_empty_dir(tmp_path) 45 | assert list(tmp_path.iterdir()) == [] 46 | -------------------------------------------------------------------------------- /tests/test_version.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | def test_version() -> None: 5 | from pyproject_api import __version__ # noqa: PLC0415 6 | 7 | assert __version__ 8 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | tox>=4.25 4 | tox-uv>=1.25 5 | env_list = 6 | fix 7 | 3.13 8 | 3.12 9 | 3.11 10 | 3.10 11 | 3.9 12 | docs 13 | type 14 | pkg_meta 15 | skip_missing_interpreters = true 16 | 17 | [testenv] 18 | description = run the unit tests with pytest under {base_python} 19 | package = wheel 20 | wheel_build_env = .pkg 21 | extras = 22 | testing 23 | pass_env = 24 | DIFF_AGAINST 25 | PYTEST_* 26 | set_env = 27 | COVERAGE_FILE = {work_dir}/.coverage.{env_name} 28 | COVERAGE_PROCESS_START = {tox_root}/pyproject.toml 29 | commands = 30 | pytest {tty:--color=yes} {posargs: \ 31 | --junitxml {work_dir}{/}junit.{env_name}.xml --cov {env_site_packages_dir}{/}pyproject_api \ 32 | --cov {tox_root}{/}tests --cov-fail-under=100 \ 33 | --cov-config=pyproject.toml --no-cov-on-fail --cov-report term-missing:skip-covered --cov-context=test \ 34 | --cov-report html:{env_tmp_dir}{/}htmlcov --cov-report xml:{work_dir}{/}coverage.{env_name}.xml \ 35 | tests} 36 | 37 | [testenv:fix] 38 | description = run static analysis and style check using flake8 39 | package = skip 40 | deps = 41 | pre-commit-uv>=4.1.4 42 | pass_env = 43 | HOMEPATH 44 | PROGRAMDATA 45 | commands = 46 | pre-commit run --all-files --show-diff-on-failure 47 | python -c 'print("hint: run {envdir}/bin/pre-commit install to add checks as pre-commit hook")' 48 | 49 | [testenv:docs] 50 | description = build documentation 51 | extras = 52 | docs 53 | commands = 54 | sphinx-build -d "{env_tmp_dir}{/}doc_tree" docs "{work_dir}{/}docs_out" --color -b html {posargs: -W} 55 | python -c 'print(r"documentation available under file://{work_dir}{/}docs_out{/}index.html")' 56 | 57 | [testenv:type] 58 | description = run type check on code base 59 | deps = 60 | mypy==1.15 61 | set_env = 62 | {tty:MYPY_FORCE_COLOR = 1} 63 | commands = 64 | mypy src 65 | mypy tests 66 | 67 | [testenv:pkg_meta] 68 | description = check that the long description is valid 69 | skip_install = true 70 | deps = 71 | check-wheel-contents>=0.6.1 72 | twine>=6.1 73 | uv>=0.7.2 74 | commands = 75 | uv build --sdist --wheel --out-dir {env_tmp_dir} . 76 | twine check {env_tmp_dir}{/}* 77 | check-wheel-contents --no-config {env_tmp_dir} 78 | 79 | [testenv:dev] 80 | description = generate a DEV environment 81 | package = editable 82 | extras = 83 | testing 84 | commands = 85 | uv pip tree 86 | python -c 'import sys; print(sys.executable)' 87 | --------------------------------------------------------------------------------