├── .github ├── codecov.yml ├── dependabot.yml ├── release.yml └── workflows │ ├── checks.yml │ ├── release.yml │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yml ├── LICENSE ├── README.md ├── docs ├── api │ └── pyproject_metadata.rst ├── changelog.md ├── conf.py └── index.md ├── noxfile.py ├── pyproject.toml ├── pyproject_metadata ├── __init__.py ├── constants.py ├── errors.py ├── project_table.py ├── py.typed └── pyproject.py └── tests ├── __init__.py ├── packages ├── broken_license │ └── LICENSE ├── dynamic-description │ ├── dynamic_description.py │ └── pyproject.toml ├── full-metadata │ ├── README.md │ ├── full_metadata.py │ └── pyproject.toml ├── full-metadata2 │ ├── LICENSE │ ├── README.rst │ ├── full_metadata2.py │ └── pyproject.toml ├── fulltext_license │ └── LICENSE.txt ├── spdx │ ├── AUTHORS.txt │ ├── LICENSE.md │ ├── LICENSE.txt │ ├── licenses │ │ └── LICENSE.MIT │ └── pyproject.toml └── unknown-readme-type │ ├── README.just-made-this-up-now │ ├── pyproject.toml │ └── unknown_readme_type.py ├── test_internals.py ├── test_rfc822.py └── test_standard_metadata.py /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | comment: false 2 | coverage: 3 | status: 4 | project: 5 | default: 6 | target: 100% 7 | patch: 8 | default: 9 | target: 100% 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | groups: 9 | actions: 10 | patterns: 11 | - "*" 12 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | authors: 4 | - dependabot 5 | - pre-commit-ci 6 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: checks 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - v* 7 | pull_request: 8 | 9 | jobs: 10 | mypy: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Setup Python 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: 3.8 19 | 20 | - name: Install nox 21 | run: python -m pip install nox 22 | 23 | - name: Run check for type 24 | run: nox -s mypy 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Package 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | branches: 8 | - main 9 | - v* 10 | release: 11 | types: 12 | - published 13 | 14 | jobs: 15 | # Always build & lint package. 16 | build-package: 17 | name: Build & verify 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: hynek/build-and-inspect-python-package@v2 23 | 24 | # Upload to real PyPI on GitHub Releases. 25 | release-pypi: 26 | name: Publish to pypi.org 27 | environment: release 28 | runs-on: ubuntu-latest 29 | needs: build-package 30 | if: github.event_name == 'release' && github.event.action == 'published' 31 | permissions: 32 | id-token: write 33 | attestations: write 34 | 35 | steps: 36 | - name: Download packages built by build-and-inspect-python-package 37 | uses: actions/download-artifact@v4 38 | with: 39 | name: Packages 40 | path: dist 41 | 42 | - name: Generate artifact attestation for sdist and wheel 43 | uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.0 44 | with: 45 | subject-path: "dist/pyproject*" 46 | 47 | - name: Upload package to PyPI 48 | uses: pypa/gh-action-pypi-publish@release/v1 49 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | branches: 8 | - main 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | pytest: 16 | runs-on: ${{ matrix.os }} 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | os: 21 | - "ubuntu-latest" 22 | python: 23 | - "3.8" 24 | - "3.9" 25 | - "3.10" 26 | - "3.11" 27 | - "3.12" 28 | - "3.13" 29 | - "3.14" 30 | include: 31 | - os: macos-13 32 | python: "3.8" 33 | - os: macos-14 34 | python: "3.12" 35 | - os: ubuntu-latest 36 | python: "pypy-3.10" 37 | - os: windows-latest 38 | python: "3.8" 39 | - os: windows-latest 40 | python: "3.11" 41 | - os: windows-latest 42 | python: "3.13" 43 | 44 | steps: 45 | - name: Checkout 46 | uses: actions/checkout@v4 47 | 48 | - name: Set up target Python 49 | uses: actions/setup-python@v5 50 | with: 51 | python-version: ${{ matrix.python }} 52 | allow-prereleases: true 53 | 54 | - name: Install the latest version of uv 55 | uses: astral-sh/setup-uv@v6 56 | 57 | - name: Run tests 58 | run: uv run noxfile.py -s test-${{ matrix.python }} 59 | 60 | - name: Run minimum tests 61 | run: uv run noxfile.py -s minimums-${{ matrix.python }} 62 | 63 | - name: Send coverage report 64 | uses: codecov/codecov-action@v5 65 | env: 66 | PYTHON: ${{ matrix.python }} 67 | with: 68 | flags: tests 69 | env_vars: PYTHON 70 | name: ${{ matrix.python }} 71 | 72 | # https://github.com/marketplace/actions/alls-green#why 73 | required-checks-pass: # This job does nothing and is only used for the branch protection 74 | if: always() 75 | 76 | needs: 77 | - pytest 78 | 79 | runs-on: ubuntu-latest 80 | 81 | steps: 82 | - name: Decide whether the needed jobs succeeded or failed 83 | uses: re-actors/alls-green@release/v1 84 | with: 85 | jobs: ${{ toJSON(needs) }} 86 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autofix_prs: false 3 | autoupdate_commit_msg: "pre-commit: bump repositories" 4 | 5 | exclude: ^tests/packages/broken_license/LICENSE$ 6 | 7 | repos: 8 | - repo: https://github.com/pre-commit/pre-commit-hooks 9 | rev: v5.0.0 10 | hooks: 11 | - id: check-ast 12 | - id: check-builtin-literals 13 | - id: check-docstring-first 14 | - id: check-merge-conflict 15 | - id: check-yaml 16 | - id: check-toml 17 | - id: debug-statements 18 | - id: end-of-file-fixer 19 | - id: trailing-whitespace 20 | 21 | - repo: https://github.com/astral-sh/ruff-pre-commit 22 | rev: "v0.11.9" 23 | hooks: 24 | - id: ruff 25 | args: ["--fix", "--show-fixes"] 26 | - id: ruff-format 27 | 28 | - repo: https://github.com/pre-commit/pygrep-hooks 29 | rev: v1.10.0 30 | hooks: 31 | - id: rst-backticks 32 | - id: rst-directive-colons 33 | - id: rst-inline-touching-normal 34 | 35 | - repo: https://github.com/adamchainz/blacken-docs 36 | rev: 1.19.1 37 | hooks: 38 | - id: blacken-docs 39 | additional_dependencies: [black==24.*] 40 | 41 | - repo: https://github.com/rbubley/mirrors-prettier 42 | rev: "v3.5.3" 43 | hooks: 44 | - id: prettier 45 | types_or: [yaml, markdown, html, css, scss, javascript, json] 46 | args: [--prose-wrap=always] 47 | 48 | - repo: https://github.com/henryiii/check-sdist 49 | rev: "v1.2.0" 50 | hooks: 51 | - id: check-sdist 52 | args: [--inject-junk] 53 | additional_dependencies: 54 | - flit-core 55 | 56 | - repo: https://github.com/codespell-project/codespell 57 | rev: v2.4.1 58 | hooks: 59 | - id: codespell 60 | exclude: ^(LICENSE$|src/scikit_build_core/resources/find_python|tests/test_skbuild_settings.py$) 61 | 62 | - repo: https://github.com/shellcheck-py/shellcheck-py 63 | rev: v0.10.0.1 64 | hooks: 65 | - id: shellcheck 66 | 67 | - repo: https://github.com/henryiii/validate-pyproject-schema-store 68 | rev: 2025.05.12 69 | hooks: 70 | - id: validate-pyproject 71 | 72 | - repo: https://github.com/python-jsonschema/check-jsonschema 73 | rev: 0.33.0 74 | hooks: 75 | - id: check-dependabot 76 | - id: check-github-workflows 77 | - id: check-readthedocs 78 | - id: check-metaschema 79 | files: \.schema\.json 80 | 81 | - repo: https://github.com/scientific-python/cookie 82 | rev: 2025.05.02 83 | hooks: 84 | - id: sp-repo-review 85 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | sphinx: 4 | configuration: docs/conf.py 5 | 6 | build: 7 | os: "ubuntu-22.04" 8 | tools: 9 | python: "3.12" 10 | commands: 11 | - asdf plugin add uv 12 | - asdf install uv latest 13 | - asdf global uv latest 14 | - uv run --group docs sphinx-build -T -b html -d docs/_build/doctrees -D 15 | language=en docs $READTHEDOCS_OUTPUT/html 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2019 Filipe Laíns 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a 4 | copy of this software and associated documentation files (the "Software"), 5 | to deal in the Software without restriction, including without limitation 6 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the 8 | Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice (including the next 11 | paragraph) shall be included in all copies or substantial portions of the 12 | Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 17 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 19 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 20 | DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pyproject-metadata 2 | 3 | [![pre-commit.ci status][pre-commit-badge]][pre-commit-link] 4 | [![checks][gha-checks-badge]][gha-checks-link] 5 | [![tests][gha-tests-badge]][gha-tests-link] 6 | [![codecov][codecov-badge]][codecov-link] 7 | [![Documentation Status][rtd-badge]][rtd-link] 8 | [![PyPI version][pypi-version]][pypi-link] 9 | 10 | > Dataclass for PEP 621 metadata with support for [core metadata] generation 11 | 12 | This project does not implement the parsing of `pyproject.toml` containing PEP 13 | 621 metadata. 14 | 15 | Instead, given a Python data structure representing PEP 621 metadata (already 16 | parsed), it will validate this input and generate a PEP 643-compliant metadata 17 | file (e.g. `PKG-INFO`). 18 | 19 | ## Usage 20 | 21 | After 22 | [installing `pyproject-metadata`](https://pypi.org/project/pyproject-metadata/), 23 | you can use it as a library in your scripts and programs: 24 | 25 | ```python 26 | from pyproject_metadata import StandardMetadata 27 | 28 | parsed_pyproject = {...} # you can use parsers like `tomli` to obtain this dict 29 | metadata = StandardMetadata.from_pyproject(parsed_pyproject, allow_extra_keys=False) 30 | print(metadata.entrypoints) # same fields as defined in PEP 621 31 | 32 | pkg_info = metadata.as_rfc822() 33 | print(str(pkg_info)) # core metadata 34 | ``` 35 | 36 | ## SPDX licenses (METADATA 2.4+) 37 | 38 | If `project.license` is a string or `project.license-files` is present, then 39 | METADATA 2.4+ will be used. A user is expected to validate and normalize 40 | `metadata.license` with an SPDX validation tool, such as the one being added to 41 | `packaging`. Add something like this (requires packaging 24.2+): 42 | 43 | ```python 44 | if isinstance(metadata.license, str): 45 | metadata.license = packaging.licenses.canonicalize_license_expression( 46 | metadata.license 47 | ) 48 | ``` 49 | 50 | A backend is also expected to copy entries from `project.licence_files`, which 51 | are paths relative to the project directory, into the `dist-info/licenses` 52 | folder, preserving the original source structure. 53 | 54 | ## Dynamic Metadata (METADATA 2.2+) 55 | 56 | Pyproject-metadata supports dynamic metadata. To use it, specify your METADATA 57 | fields in `dynamic_metadata`. If you want to convert `pyproject.toml` field 58 | names to METADATA field(s), use 59 | `pyproject_metadata.pyproject_to_metadata("field-name")`, which will return a 60 | frozenset of metadata names that are touched by that field. 61 | 62 | ## Adding extra fields 63 | 64 | You can add extra fields to the Message returned by `to_rfc822()`, as long as 65 | they are valid metadata entries. 66 | 67 | ## Collecting multiple errors 68 | 69 | You can use the `all_errors` argument to `from_pyproject` to show all errors in 70 | the metadata parse at once, instead of raising an exception on the first one. 71 | The exception type will be `pyproject_metadata.errors.ExceptionGroup` (which is 72 | just `ExceptionGroup` on Python 3.11+). 73 | 74 | ## Validating extra fields 75 | 76 | By default, a warning (`pyproject_metadata.errors.ExtraKeyWarning`) will be 77 | issued for extra fields at the project table. You can pass `allow_extra_keys=` 78 | to either avoid the check (`True`) or hard error (`False`). If you want to 79 | detect extra keys, you can get them with `pyproject_metadata.extra_top_level` 80 | and `pyproject_metadata.extra_build_system`. It is recommended that build 81 | systems only warn on failures with these extra keys. 82 | 83 | ## Validating classifiers 84 | 85 | If you want to validate classifiers, then install the `trove_classifiers` 86 | library (the canonical source for classifiers), and run: 87 | 88 | ```python 89 | import trove_classifiers 90 | 91 | metadata_classifieres = { 92 | c for c in metadata.classifiers if not c.startswith("Private ::") 93 | } 94 | invalid_classifiers = set(metadata.classifiers) - trove_classifiers.classifiers 95 | 96 | # Also the deprecated dict if you want it 97 | dep_names = set(metadata.classifiers) & set(trove_classifiers.deprecated_classifiers) 98 | deprecated_classifiers = { 99 | k: trove_classifiers.deprecated_classifiers[k] for k in dep_names 100 | } 101 | ``` 102 | 103 | If you are writing a build backend, you should not validate classifiers with a 104 | `Private ::` prefix; these are only restricted for upload to PyPI (such as 105 | `Private :: Do Not Upload`). 106 | 107 | Since classifiers are a moving target, it is probably best for build backends 108 | (which may be shipped by third party distributors like Debian or Fedora) to 109 | either ignore or have optional classifier validation. 110 | 111 | 112 | [core metadata]: https://packaging.python.org/specifications/core-metadata/ 113 | [gha-checks-link]: https://github.com/pypa/pyproject-metadata/actions/workflows/checks.yml 114 | [gha-checks-badge]: https://github.com/pypa/pyproject-metadata/actions/workflows/checks.yml/badge.svg 115 | [gha-tests-link]: https://github.com/pypa/pyproject-metadata/actions/workflows/tests.yml 116 | [gha-tests-badge]: https://github.com/pypa/pyproject-metadata/actions/workflows/tests.yml/badge.svg 117 | [pre-commit-link]: https://results.pre-commit.ci/latest/github/pypa/pyproject-metadata/main 118 | [pre-commit-badge]: https://results.pre-commit.ci/badge/github/pypa/pyproject-metadata/main.svg 119 | [codecov-link]: https://codecov.io/gh/pypa/pyproject-metadata 120 | [codecov-badge]: https://codecov.io/gh/pypa/pyproject-metadata/branch/main/graph/badge.svg?token=9chBjS1lch 121 | [pypi-link]: https://pypi.org/project/pyproject-metadata/ 122 | [pypi-version]: https://badge.fury.io/py/pyproject-metadata.svg 123 | [rtd-link]: https://pep621.readthedocs.io/en/latest/?badge=latest 124 | [rtd-badge]: https://readthedocs.org/projects/pep621/badge/?version=latest 125 | 126 | -------------------------------------------------------------------------------- /docs/api/pyproject_metadata.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | .. automodule:: pyproject_metadata 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | :exclude-members: ConfigurationError 9 | 10 | Submodules 11 | ---------- 12 | 13 | pyproject\_metadata.constants module 14 | ------------------------------------ 15 | 16 | .. automodule:: pyproject_metadata.constants 17 | :members: 18 | :undoc-members: 19 | :show-inheritance: 20 | 21 | pyproject\_metadata.errors module 22 | --------------------------------- 23 | 24 | .. automodule:: pyproject_metadata.errors 25 | :members: 26 | :undoc-members: 27 | :show-inheritance: 28 | 29 | pyproject\_metadata.project\_table module 30 | ----------------------------------------- 31 | 32 | .. automodule:: pyproject_metadata.project_table 33 | :members: 34 | :undoc-members: 35 | :show-inheritance: 36 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | - Remove Python 3.7 support 6 | 7 | ## 0.9.1 (10-03-2024) 8 | 9 | This release fixes form feeds in License files using pre-PEP 639 syntax when 10 | using Python older than 3.12.4; this is a regression in 0.9.0 from moving to the 11 | standard library email module. Some other small fixes to validation messages 12 | were applied. 13 | 14 | Fixes: 15 | 16 | - Handle form feed for Python <3.12.4 17 | - Some touchup based on packaging PR 18 | 19 | Docs: 20 | 21 | - Fix `packaging.licenses` example code 22 | 23 | Internal and CI: 24 | 25 | - Speed up CI a bit, add Python 3.14 alpha testing 26 | 27 | ## 0.9.0 (22-10-2024) 28 | 29 | This release adds PEP 639 support (METADATA 2.4), refactors the RFC messages, 30 | and adds a lot of validation (including warnings and opt-in errors), a way to 31 | produce all validation errors at once, and more. The beta releases are intended 32 | for backend authors to try out the changes before a final release. 33 | 34 | Features: 35 | 36 | - Added PEP 639 support for SPDX license and license files, METADATA 2.4 37 | - Validate extra keys (warning, opt-in error) 38 | - Functions to check top level and build-system (including PEP 735 support) 39 | - Add TypedDict's in new module for typing pyproject.toml dicts 40 | - `all_errors=True` causes `ExceptionGroup`'s to be emitted 41 | - Support METADATA 2.1+ JSON format with new `.as_json()` method 42 | 43 | Fixes: 44 | 45 | - Match EmailMessage spacing 46 | - Handle multilines the way setuptools does with smart indentation 47 | - Warn on multiline Summary (`project.description`) 48 | - Improve locking for just metadata fields 49 | - Error on extra keys in author/maintainer 50 | - URL name stylization removed matching PEP 753 51 | 52 | Refactoring: 53 | 54 | - Move fetcher methods 55 | - Put validation in method 56 | - Make `RFC822Message` compatible with and subclass of `EmailMessage` class with 57 | support for Unicode 58 | - Remove indirection accessing `metadata_version`, add `auto_metadata_version` 59 | - Rework how dynamic works, add `dynamic_metadata` 60 | - Use dataclass instead of named tuple 61 | - Use named arguments instead of positional 62 | - Spit up over multiple files 63 | - Remove `DataFetcher`, use static types wherever possible 64 | - Reformat single quotes to double quotes to match packaging 65 | - Produce standard Python repr style in error messages (keeping double quotes 66 | for key names) 67 | - Show the types instead of values in error messages 68 | 69 | Internal and CI: 70 | 71 | - Better changelog auto-generation 72 | - `macos-latest` now points at `macos-14` 73 | - Refactor and cleanup tests 74 | - Add human readable IDs to tests 75 | - Require 100% coverage 76 | 77 | Docs: 78 | 79 | - Include extra badge in readme 80 | - Rework docs, include README and more classes 81 | - Changelog is now in markdown 82 | - Better API section 83 | 84 | ## 0.8.1 (07-10-2024) 85 | 86 | - Validate project name 87 | - Validate entrypoint group names 88 | - Correct typing for emails 89 | - Add 3.13 to testing 90 | - Add ruff-format 91 | - Actions and dependabot 92 | - Generate GitHub attestations for releases 93 | - Add PyPI attestations 94 | - Fix coverage context 95 | 96 | ## 0.8.0 (17-04-2024) 97 | 98 | - Support specifying the `metadata_version` as 2.1, 2.2, or 2.3 99 | - Always normalize extras following PEP 685 100 | - Preserve the user-specified name style in the metadata. `.canonical_name` 101 | added to get the normalized name 102 | - Require "version" in the dynamic table if unset (following PEP 621) 103 | - Support extras using markers containing "or" 104 | - Support empty extras 105 | - Using `.as_rfc822()` no longer modifies the metadata object 106 | - Fix email-author listing for names containing commas 107 | - Separate core metadata keywords with commas, following the (modified) spec 108 | - An error message reported `project.license` instead of `project.readme` 109 | - Produce slightly cleaner tracebacks Fix a typo in an exception message 110 | - Subclasses now type check correctly 111 | - The build backend is now `flit-core` 112 | 113 | ## 0.7.1 (30-01-2023) 114 | 115 | - Relax `pypa/packaging` dependency 116 | 117 | ## 0.7.0 (18-01-2023) 118 | 119 | - Use UTF-8 when opening files 120 | - Use `tomllib` on Python \>= 3.11 121 | 122 | ## 0.6.1 (07-07-2022) 123 | 124 | - Avoid first and last newlines in license contents 125 | 126 | ## 0.6.0 (06-07-2022) 127 | 128 | - Make license and readme files `pathlib.Path` instances 129 | - Add the license contents to the metadata file 130 | - Add support for multiline data in metadata fields 131 | 132 | ## 0.5.0 (09-06-2022) 133 | 134 | - Renamed project to `pyproject_metadata` 135 | - Support multiple clauses in requires-python 136 | - Error out when dynamic fields are defined 137 | - Update dynamic field when setting version 138 | 139 | ## 0.4.0 (30-09-2021) 140 | 141 | - Use Core Metadata 2.1 if possible 142 | - Fix bug preventing empty README and license files from being used 143 | 144 | ## 0.3.1 (25-09-2021) 145 | 146 | - Avoid core metadata `Author`/`Maintainer` fields in favor of 147 | `Author-Email`/`Maintainer-Email` 148 | 149 | ## 0.3.0.post2 (15-09-2021) 150 | 151 | - Fix Python version requirement 152 | 153 | ## 0.3.0.post1 (13-09-2021) 154 | 155 | - Add documentation 156 | 157 | ## 0.3.0 (13-09-2021) 158 | 159 | - Added `RFC822Message` 160 | - Refactor `StandardMetadata` as a dataclass 161 | - Added `StandardMetadata.write_to_rfc822` and `StandardMetadata.as_rfc822` 162 | 163 | ## 0.1.0 (25-08-2021) 164 | 165 | - Initial release 166 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | 13 | import pyproject_metadata 14 | 15 | # -- Project information ----------------------------------------------------- 16 | 17 | project = "pyproject-metadata" 18 | copyright = "2021, Filipe Laíns" 19 | author = "Filipe Laíns" 20 | 21 | # The short X.Y version 22 | version = pyproject_metadata.__version__ 23 | # The full version, including alpha/beta/rc tags 24 | release = pyproject_metadata.__version__ 25 | 26 | 27 | # -- General configuration --------------------------------------------------- 28 | 29 | # Add any Sphinx extension module names here, as strings. They can be 30 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 31 | # ones. 32 | extensions = [ 33 | "myst_parser", 34 | "sphinx.ext.autodoc", 35 | "sphinx.ext.intersphinx", 36 | "sphinx.ext.viewcode", 37 | "sphinx_autodoc_typehints", 38 | ] 39 | 40 | intersphinx_mapping = { 41 | "python": ("https://docs.python.org/3/", None), 42 | "packaging": ("https://packaging.pypa.io/en/latest/", None), 43 | } 44 | 45 | # Add any paths that contain templates here, relative to this directory. 46 | templates_path = [] 47 | 48 | source_suffix = [".rst", ".md"] 49 | 50 | # -- Options for HTML output ------------------------------------------------- 51 | 52 | # The theme to use for HTML and HTML Help pages. See the documentation for 53 | # a list of builtin themes. 54 | # 55 | html_theme = "furo" 56 | html_title = f"pyproject-metadata {version}" 57 | 58 | # Add any paths that contain custom static files (such as style sheets) here, 59 | # relative to this directory. They are copied after the builtin static files, 60 | # so a file named 'default.css' will overwrite the builtin 'default.css'. 61 | # html_static_path = ['_static'] 62 | 63 | autodoc_default_options = { 64 | "member-order": "bysource", 65 | } 66 | 67 | autoclass_content = "both" 68 | 69 | # Type hints 70 | 71 | always_use_bars_union = True 72 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ```{include} ../README.md 2 | 3 | ``` 4 | 5 | ```{toctree} 6 | :caption: Contents: 7 | :maxdepth: 2 8 | 9 | api/pyproject_metadata 10 | changelog 11 | ``` 12 | 13 | ```{toctree} 14 | :caption: Links: 15 | :hidden: 16 | 17 | Source Code 18 | Issue Tracker 19 | ``` 20 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | # /// script 4 | # dependencies = ["nox >=2025.2.9"] 5 | # /// 6 | 7 | import argparse 8 | import os 9 | import os.path 10 | 11 | import nox 12 | 13 | nox.needs_version = ">=2025.2.9" 14 | nox.options.default_venv_backend = "uv|virtualenv" 15 | 16 | PYPROJECT = nox.project.load_toml("pyproject.toml") 17 | ALL_PYTHONS = nox.project.python_versions(PYPROJECT) 18 | ALL_PYTHONS += ["pypy-3.10"] 19 | 20 | 21 | @nox.session(python="3.8") 22 | def mypy(session: nox.Session) -> None: 23 | """ 24 | Run a type checker. 25 | """ 26 | session.install(".", "mypy", "nox", "pytest") 27 | 28 | session.run("mypy", "pyproject_metadata", "tests", "noxfile.py") 29 | 30 | 31 | @nox.session(python=ALL_PYTHONS) 32 | def test(session: nox.Session) -> None: 33 | """ 34 | Run the test suite. 35 | """ 36 | htmlcov_output = os.path.join(session.virtualenv.location, "htmlcov") 37 | xmlcov_output = os.path.join( 38 | session.virtualenv.location, f"coverage-{session.python}.xml" 39 | ) 40 | 41 | test_grp = nox.project.dependency_groups(PYPROJECT, "test") 42 | session.install("-e.", *test_grp) 43 | 44 | session.run( 45 | "pytest", 46 | "--cov", 47 | f"--cov-report=html:{htmlcov_output}", 48 | f"--cov-report=xml:{xmlcov_output}", 49 | "--cov-report=term-missing", 50 | "--cov-context=test", 51 | "tests/", 52 | *session.posargs, 53 | ) 54 | 55 | 56 | @nox.session(venv_backend="uv", default=False, python=ALL_PYTHONS) 57 | def minimums(session: nox.Session) -> None: 58 | """ 59 | Check minimum requirements. 60 | """ 61 | test_grp = nox.project.dependency_groups(PYPROJECT, "test") 62 | session.install("-e.", "--resolution=lowest-direct", *test_grp, silent=False) 63 | 64 | xmlcov_output = os.path.join( 65 | session.virtualenv.location, f"coverage-{session.python}-min.xml" 66 | ) 67 | 68 | session.run( 69 | "pytest", 70 | "--cov", 71 | f"--cov-report=xml:{xmlcov_output}", 72 | "--cov-report=term-missing", 73 | "--cov-context=test", 74 | "tests/", 75 | *session.posargs, 76 | ) 77 | 78 | 79 | @nox.session(default=False) 80 | def docs(session: nox.Session) -> None: 81 | """ 82 | Build the docs. Use "--non-interactive" to avoid serving. Pass "-b linkcheck" to check links. 83 | """ 84 | 85 | parser = argparse.ArgumentParser() 86 | parser.add_argument( 87 | "-b", dest="builder", default="html", help="Build target (default: html)" 88 | ) 89 | args, posargs = parser.parse_known_args(session.posargs) 90 | 91 | serve = args.builder == "html" and session.interactive 92 | extra_installs = ["sphinx-autobuild"] if serve else [] 93 | docs_grp = nox.project.dependency_groups(PYPROJECT, "docs") 94 | session.install("-e.", *docs_grp, *extra_installs) 95 | 96 | session.chdir("docs") 97 | 98 | shared_args = ( 99 | "-n", # nitpicky mode 100 | "-T", # full tracebacks 101 | f"-b={args.builder}", 102 | ".", 103 | f"_build/{args.builder}", 104 | *posargs, 105 | ) 106 | 107 | if serve: 108 | session.run("sphinx-autobuild", "--open-browser", *shared_args) 109 | else: 110 | session.run("sphinx-build", "--keep-going", *shared_args) 111 | 112 | 113 | if __name__ == "__main__": 114 | nox.main() 115 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit-core>=3.11"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "pyproject-metadata" 7 | dynamic = ["version"] 8 | description = "PEP 621 metadata parsing" 9 | readme = "README.md" 10 | requires-python = ">=3.8" 11 | license = "MIT" 12 | license-files = ["LICENSE"] 13 | authors = [ 14 | { name = "Filipe Laíns", email = "lains@riseup.net" }, 15 | ] 16 | classifiers = [ 17 | "Programming Language :: Python :: 3", 18 | "Programming Language :: Python :: 3 :: Only", 19 | "Programming Language :: Python :: 3.8", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | "Programming Language :: Python :: 3.13", 25 | "Programming Language :: Python :: 3.14", 26 | ] 27 | dependencies = [ 28 | "packaging>=23.2", 29 | ] 30 | 31 | [project.urls] 32 | changelog = "https://pep621.readthedocs.io/en/stable/changelog.html" 33 | homepage = "https://github.com/pypa/pyproject-metadata" 34 | 35 | [dependency-groups] 36 | docs = [ 37 | "furo>=2023.9.10", 38 | "sphinx-autodoc-typehints>=1.10.0", 39 | "sphinx>=7.0", 40 | "sphinx-autodoc-typehints", 41 | "myst-parser", 42 | ] 43 | test = [ 44 | "pytest-cov>=4", 45 | "pytest>=7.4; python_version>='3.12'", 46 | "pytest>=7; python_version<'3.12'", 47 | 'tomli>=1.1;python_version<"3.11"', 48 | 'exceptiongroup>=1.0;python_version<"3.11"', # Optional 49 | ] 50 | dev = [{include-group = "test"}] 51 | 52 | 53 | [tool.flit.sdist] 54 | include = ["LICENSE", "tests/**", "docs/**", ".gitignore"] 55 | 56 | 57 | [tool.pytest.ini_options] 58 | minversion = "7.0" 59 | addopts = ["-ra", "--showlocals", "--strict-markers", "--strict-config"] 60 | xfail_strict = true 61 | filterwarnings = ["error"] 62 | log_cli_level = "INFO" 63 | testpaths = ["tests"] 64 | 65 | 66 | [tool.mypy] 67 | strict = true 68 | warn_unreachable = false 69 | enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] 70 | 71 | 72 | [tool.ruff.lint] 73 | extend-select = [ 74 | "C90", # mccabe 75 | "B", # flake8-bugbear 76 | "I", # isort 77 | "ARG", # flake8-unused-arguments 78 | "C4", # flake8-comprehensions 79 | "ICN", # flake8-import-conventions 80 | "ISC", # flake8-implicit-str-concat 81 | "EM", # flake8-errmsg 82 | "G", # flake8-logging-format 83 | "PGH", # pygrep-hooks 84 | "PIE", # flake8-pie 85 | "PL", # pylint 86 | "PT", # flake8-pytest-style 87 | "RET", # flake8-return 88 | "RUF", # Ruff-specific 89 | "SIM", # flake8-simplify 90 | "T20", # flake8-print 91 | "UP", # pyupgrade 92 | "YTT", # flake8-2020 93 | "EXE", # flake8-executable 94 | "NPY", # NumPy specific rules 95 | "PD", # pandas-vet 96 | ] 97 | ignore = [ 98 | "ISC001", # conflicts with formatter 99 | "PLR09", # Design related (too many X) 100 | "PLR2004", # Magic value in comparison 101 | ] 102 | 103 | [tool.ruff.format] 104 | docstring-code-format = true 105 | 106 | [tool.coverage] 107 | html.show_contexts = true 108 | report.exclude_also = [ 109 | "if typing.TYPE_CHECKING:", 110 | ] 111 | 112 | [tool.repo-review] 113 | ignore = ["PC140"] 114 | -------------------------------------------------------------------------------- /pyproject_metadata/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | """ 4 | This is pyproject_metadata, a library for working with PEP 621 metadata. 5 | 6 | Example usage: 7 | 8 | .. code-block:: python 9 | 10 | from pyproject_metadata import StandardMetadata 11 | 12 | metadata = StandardMetadata.from_pyproject( 13 | parsed_pyproject, allow_extra_keys=False, all_errors=True, metadata_version="2.3" 14 | ) 15 | 16 | pkg_info = metadata.as_rfc822() 17 | with open("METADATA", "wb") as f: 18 | f.write(pkg_info.as_bytes()) 19 | 20 | ep = self.metadata.entrypoints.copy() 21 | ep["console_scripts"] = self.metadata.scripts 22 | ep["gui_scripts"] = self.metadata.gui_scripts 23 | for group, entries in ep.items(): 24 | if entries: 25 | with open("entry_points.txt", "w", encoding="utf-8") as f: 26 | print(f"[{group}]", file=f) 27 | for name, target in entries.items(): 28 | print(f"{name} = {target}", file=f) 29 | print(file=f) 30 | 31 | """ 32 | 33 | from __future__ import annotations 34 | 35 | import copy 36 | import dataclasses 37 | import email.message 38 | import email.policy 39 | import email.utils 40 | import os 41 | import os.path 42 | import pathlib 43 | import sys 44 | import typing 45 | import warnings 46 | 47 | # Build backends may vendor this package, so all imports are relative. 48 | from . import constants 49 | from .errors import ConfigurationError, ConfigurationWarning, ErrorCollector 50 | from .pyproject import License, PyProjectReader, Readme 51 | 52 | if typing.TYPE_CHECKING: 53 | from collections.abc import Mapping 54 | from typing import Any 55 | 56 | from packaging.requirements import Requirement 57 | 58 | if sys.version_info < (3, 11): 59 | from typing_extensions import Self 60 | else: 61 | from typing import Self 62 | 63 | from .project_table import Dynamic, PyProjectTable 64 | 65 | import packaging.markers 66 | import packaging.specifiers 67 | import packaging.utils 68 | import packaging.version 69 | 70 | if sys.version_info < (3, 12, 4): 71 | import re 72 | 73 | RE_EOL_STR = re.compile(r"[\r\n]+") 74 | RE_EOL_BYTES = re.compile(rb"[\r\n]+") 75 | 76 | 77 | __version__ = "0.9.1" 78 | 79 | __all__ = [ 80 | "ConfigurationError", 81 | "License", 82 | "RFC822Message", 83 | "RFC822Policy", 84 | "Readme", 85 | "StandardMetadata", 86 | "extras_build_system", 87 | "extras_project", 88 | "extras_top_level", 89 | "field_to_metadata", 90 | ] 91 | 92 | 93 | def __dir__() -> list[str]: 94 | return __all__ 95 | 96 | 97 | def field_to_metadata(field: str) -> frozenset[str]: 98 | """ 99 | Return the METADATA fields that correspond to a project field. 100 | """ 101 | return frozenset(constants.PROJECT_TO_METADATA[field]) 102 | 103 | 104 | def extras_top_level(pyproject_table: Mapping[str, Any]) -> set[str]: 105 | """ 106 | Return any extra keys in the top-level of the pyproject table. 107 | """ 108 | return set(pyproject_table) - constants.KNOWN_TOPLEVEL_FIELDS 109 | 110 | 111 | def extras_build_system(pyproject_table: Mapping[str, Any]) -> set[str]: 112 | """ 113 | Return any extra keys in the build-system table. 114 | """ 115 | return ( 116 | set(pyproject_table.get("build-system", [])) 117 | - constants.KNOWN_BUILD_SYSTEM_FIELDS 118 | ) 119 | 120 | 121 | def extras_project(pyproject_table: Mapping[str, Any]) -> set[str]: 122 | """ 123 | Return any extra keys in the project table. 124 | """ 125 | return set(pyproject_table.get("project", [])) - constants.KNOWN_PROJECT_FIELDS 126 | 127 | 128 | @dataclasses.dataclass 129 | class _SmartMessageSetter: 130 | """ 131 | This provides a nice internal API for setting values in an Message to 132 | reduce boilerplate. 133 | 134 | If a value is None, do nothing. 135 | """ 136 | 137 | message: email.message.Message 138 | 139 | def __setitem__(self, name: str, value: str | None) -> None: 140 | if not value: 141 | return 142 | self.message[name] = value 143 | 144 | def set_payload(self, payload: str) -> None: 145 | self.message.set_payload(payload) 146 | 147 | 148 | @dataclasses.dataclass 149 | class _JSonMessageSetter: 150 | """ 151 | This provides an API to build a JSON message output in the same way as the 152 | classic Message. Line breaks are preserved this way. 153 | """ 154 | 155 | data: dict[str, str | list[str]] 156 | 157 | def __setitem__(self, name: str, value: str | None) -> None: 158 | name = name.lower() 159 | key = name.replace("-", "_") 160 | 161 | if value is None: 162 | return 163 | 164 | if name == "keywords": 165 | values = (x.strip() for x in value.split(",")) 166 | self.data[key] = [x for x in values if x] 167 | elif name in constants.KNOWN_MULTIUSE: 168 | entry = self.data.setdefault(key, []) 169 | assert isinstance(entry, list) 170 | entry.append(value) 171 | else: 172 | self.data[key] = value 173 | 174 | def set_payload(self, payload: str) -> None: 175 | self["description"] = payload 176 | 177 | 178 | class RFC822Policy(email.policy.EmailPolicy): 179 | """ 180 | This is :class:`email.policy.EmailPolicy`, but with a simple ``header_store_parse`` 181 | implementation that handles multiline values, and some nice defaults. 182 | """ 183 | 184 | utf8 = True 185 | mangle_from_ = False 186 | max_line_length = 0 187 | 188 | def header_store_parse(self, name: str, value: str) -> tuple[str, str]: 189 | if name.lower() not in constants.KNOWN_METADATA_FIELDS: 190 | msg = f"Unknown field {name!r}" 191 | raise ConfigurationError(msg, key=name) 192 | size = len(name) + 2 193 | value = value.replace("\n", "\n" + " " * size) 194 | return (name, value) 195 | 196 | if sys.version_info < (3, 12, 4): 197 | # Work around Python bug https://github.com/python/cpython/issues/117313 198 | def _fold( 199 | self, name: str, value: Any, refold_binary: bool = False 200 | ) -> str: # pragma: no cover 201 | if hasattr(value, "name"): 202 | return value.fold(policy=self) # type: ignore[no-any-return] 203 | maxlen = self.max_line_length if self.max_line_length else sys.maxsize 204 | 205 | # this is from the library version, and it improperly breaks on chars like 0x0c, treating 206 | # them as 'form feed' etc. 207 | # we need to ensure that only CR/LF is used as end of line 208 | # lines = value.splitlines() 209 | 210 | # this is a workaround which splits only on CR/LF characters 211 | if isinstance(value, bytes): 212 | lines = RE_EOL_BYTES.split(value) 213 | else: 214 | lines = RE_EOL_STR.split(value) 215 | 216 | refold = self.refold_source == "all" or ( 217 | self.refold_source == "long" 218 | and ( 219 | (lines and len(lines[0]) + len(name) + 2 > maxlen) 220 | or any(len(x) > maxlen for x in lines[1:]) 221 | ) 222 | ) 223 | if refold or (refold_binary and email.policy._has_surrogates(value)): # type: ignore[attr-defined] 224 | return self.header_factory(name, "".join(lines)).fold(policy=self) # type: ignore[arg-type,no-any-return] 225 | return name + ": " + self.linesep.join(lines) + self.linesep # type: ignore[arg-type] 226 | 227 | 228 | class RFC822Message(email.message.EmailMessage): 229 | """ 230 | This is :class:`email.message.EmailMessage` with two small changes: it defaults to 231 | our `RFC822Policy`, and it correctly writes unicode when being called 232 | with `bytes()`. 233 | """ 234 | 235 | def __init__(self) -> None: 236 | super().__init__(policy=RFC822Policy()) 237 | 238 | def as_bytes( 239 | self, unixfrom: bool = False, policy: email.policy.Policy | None = None 240 | ) -> bytes: 241 | """ 242 | This handles unicode encoding. 243 | """ 244 | return self.as_string(unixfrom, policy=policy).encode("utf-8") 245 | 246 | 247 | @dataclasses.dataclass 248 | class StandardMetadata: 249 | """ 250 | This class represents the standard metadata fields for a project. It can be 251 | used to read metadata from a pyproject.toml table, validate it, and write it 252 | to an RFC822 message or JSON. 253 | """ 254 | 255 | name: str 256 | version: packaging.version.Version | None = None 257 | description: str | None = None 258 | license: License | str | None = None 259 | license_files: list[pathlib.Path] | None = None 260 | readme: Readme | None = None 261 | requires_python: packaging.specifiers.SpecifierSet | None = None 262 | dependencies: list[Requirement] = dataclasses.field(default_factory=list) 263 | optional_dependencies: dict[str, list[Requirement]] = dataclasses.field( 264 | default_factory=dict 265 | ) 266 | entrypoints: dict[str, dict[str, str]] = dataclasses.field(default_factory=dict) 267 | authors: list[tuple[str, str | None]] = dataclasses.field(default_factory=list) 268 | maintainers: list[tuple[str, str | None]] = dataclasses.field(default_factory=list) 269 | urls: dict[str, str] = dataclasses.field(default_factory=dict) 270 | classifiers: list[str] = dataclasses.field(default_factory=list) 271 | keywords: list[str] = dataclasses.field(default_factory=list) 272 | scripts: dict[str, str] = dataclasses.field(default_factory=dict) 273 | gui_scripts: dict[str, str] = dataclasses.field(default_factory=dict) 274 | dynamic: list[Dynamic] = dataclasses.field(default_factory=list) 275 | """ 276 | This field is used to track dynamic fields. You can't set a field not in this list. 277 | """ 278 | 279 | dynamic_metadata: list[str] = dataclasses.field(default_factory=list) 280 | """ 281 | This is a list of METADATA fields that can change in between SDist and wheel. Requires metadata_version 2.2+. 282 | """ 283 | metadata_version: str | None = None 284 | """ 285 | This is the target metadata version. If None, it will be computed as a minimum based on the fields set. 286 | """ 287 | all_errors: bool = False 288 | """ 289 | If True, all errors will be collected and raised in an ExceptionGroup. 290 | """ 291 | 292 | def __post_init__(self) -> None: 293 | self.validate() 294 | 295 | @property 296 | def auto_metadata_version(self) -> str: 297 | """ 298 | This computes the metadata version based on the fields set in the object 299 | if ``metadata_version`` is None. 300 | """ 301 | if self.metadata_version is not None: 302 | return self.metadata_version 303 | 304 | if isinstance(self.license, str) or self.license_files is not None: 305 | return "2.4" 306 | if self.dynamic_metadata: 307 | return "2.2" 308 | return "2.1" 309 | 310 | @property 311 | def canonical_name(self) -> str: 312 | """ 313 | Return the canonical name of the project. 314 | """ 315 | return packaging.utils.canonicalize_name(self.name) 316 | 317 | @classmethod 318 | def from_pyproject( # noqa: C901 319 | cls, 320 | data: Mapping[str, Any], 321 | project_dir: str | os.PathLike[str] = os.path.curdir, 322 | metadata_version: str | None = None, 323 | dynamic_metadata: list[str] | None = None, 324 | *, 325 | allow_extra_keys: bool | None = None, 326 | all_errors: bool = False, 327 | ) -> Self: 328 | """ 329 | Read metadata from a pyproject.toml table. This is the main method for 330 | creating an instance of this class. It also supports two additional 331 | fields: ``allow_extra_keys`` to control what happens when extra keys are 332 | present in the pyproject table, and ``all_errors``, to raise all errors 333 | in an ExceptionGroup instead of raising the first one. 334 | """ 335 | pyproject = PyProjectReader(collect_errors=all_errors) 336 | 337 | pyproject_table: PyProjectTable = data # type: ignore[assignment] 338 | if "project" not in pyproject_table: 339 | msg = "Section {key} missing in pyproject.toml" 340 | pyproject.config_error(msg, key="project") 341 | pyproject.finalize("Failed to parse pyproject.toml") 342 | msg = "Unreachable code" # pragma: no cover 343 | raise AssertionError(msg) # pragma: no cover 344 | 345 | project = pyproject_table["project"] 346 | project_dir = pathlib.Path(project_dir) 347 | 348 | if not allow_extra_keys: 349 | extra_keys = extras_project(data) 350 | if extra_keys: 351 | extra_keys_str = ", ".join(sorted(f"{k!r}" for k in extra_keys)) 352 | msg = "Extra keys present in {key}: {extra_keys}" 353 | pyproject.config_error( 354 | msg, 355 | key="project", 356 | extra_keys=extra_keys_str, 357 | warn=allow_extra_keys is None, 358 | ) 359 | 360 | dynamic = pyproject.get_dynamic(project) 361 | 362 | for field in dynamic: 363 | if field in data["project"]: 364 | msg = 'Field {key} declared as dynamic in "project.dynamic" but is defined' 365 | pyproject.config_error(msg, key=f"project.{field}") 366 | 367 | raw_name = project.get("name") 368 | name = "UNKNOWN" 369 | if raw_name is None: 370 | msg = "Field {key} missing" 371 | pyproject.config_error(msg, key="project.name") 372 | else: 373 | tmp_name = pyproject.ensure_str(raw_name, "project.name") 374 | if tmp_name is not None: 375 | name = tmp_name 376 | 377 | version: packaging.version.Version | None = packaging.version.Version("0.0.0") 378 | raw_version = project.get("version") 379 | if raw_version is not None: 380 | version_string = pyproject.ensure_str(raw_version, "project.version") 381 | if version_string is not None: 382 | try: 383 | version = ( 384 | packaging.version.Version(version_string) 385 | if version_string 386 | else None 387 | ) 388 | except packaging.version.InvalidVersion: 389 | msg = "Invalid {key} value, expecting a valid PEP 440 version" 390 | pyproject.config_error( 391 | msg, key="project.version", got=version_string 392 | ) 393 | elif "version" not in dynamic: 394 | msg = ( 395 | "Field {key} missing and 'version' not specified in \"project.dynamic\"" 396 | ) 397 | pyproject.config_error(msg, key="project.version") 398 | 399 | # Description fills Summary, which cannot be multiline 400 | # However, throwing an error isn't backward compatible, 401 | # so leave it up to the users for now. 402 | project_description_raw = project.get("description") 403 | description = ( 404 | pyproject.ensure_str(project_description_raw, "project.description") 405 | if project_description_raw is not None 406 | else None 407 | ) 408 | 409 | requires_python_raw = project.get("requires-python") 410 | requires_python = None 411 | if requires_python_raw is not None: 412 | requires_python_string = pyproject.ensure_str( 413 | requires_python_raw, "project.requires-python" 414 | ) 415 | if requires_python_string is not None: 416 | try: 417 | requires_python = packaging.specifiers.SpecifierSet( 418 | requires_python_string 419 | ) 420 | except packaging.specifiers.InvalidSpecifier: 421 | msg = "Invalid {key} value, expecting a valid specifier set" 422 | pyproject.config_error( 423 | msg, key="project.requires-python", got=requires_python_string 424 | ) 425 | 426 | self = None 427 | with pyproject.collect(): 428 | self = cls( 429 | name=name, 430 | version=version, 431 | description=description, 432 | license=pyproject.get_license(project, project_dir), 433 | license_files=pyproject.get_license_files(project, project_dir), 434 | readme=pyproject.get_readme(project, project_dir), 435 | requires_python=requires_python, 436 | dependencies=pyproject.get_dependencies(project), 437 | optional_dependencies=pyproject.get_optional_dependencies(project), 438 | entrypoints=pyproject.get_entrypoints(project), 439 | authors=pyproject.ensure_people( 440 | project.get("authors", []), "project.authors" 441 | ), 442 | maintainers=pyproject.ensure_people( 443 | project.get("maintainers", []), "project.maintainers" 444 | ), 445 | urls=pyproject.ensure_dict(project.get("urls", {}), "project.urls") 446 | or {}, 447 | classifiers=pyproject.ensure_list( 448 | project.get("classifiers", []), "project.classifiers" 449 | ) 450 | or [], 451 | keywords=pyproject.ensure_list( 452 | project.get("keywords", []), "project.keywords" 453 | ) 454 | or [], 455 | scripts=pyproject.ensure_dict( 456 | project.get("scripts", {}), "project.scripts" 457 | ) 458 | or {}, 459 | gui_scripts=pyproject.ensure_dict( 460 | project.get("gui-scripts", {}), "project.gui-scripts" 461 | ) 462 | or {}, 463 | dynamic=dynamic, 464 | dynamic_metadata=dynamic_metadata or [], 465 | metadata_version=metadata_version, 466 | all_errors=all_errors, 467 | ) 468 | 469 | pyproject.finalize("Failed to parse pyproject.toml") 470 | assert self is not None 471 | return self 472 | 473 | def as_rfc822(self) -> RFC822Message: 474 | """ 475 | Return an RFC822 message with the metadata. 476 | """ 477 | message = RFC822Message() 478 | smart_message = _SmartMessageSetter(message) 479 | self._write_metadata(smart_message) 480 | return message 481 | 482 | def as_json(self) -> dict[str, str | list[str]]: 483 | """ 484 | Return a JSON message with the metadata. 485 | """ 486 | message: dict[str, str | list[str]] = {} 487 | smart_message = _JSonMessageSetter(message) 488 | self._write_metadata(smart_message) 489 | return message 490 | 491 | def validate(self, *, warn: bool = True) -> None: # noqa: C901 492 | """ 493 | Validate metadata for consistency and correctness. Will also produce 494 | warnings if ``warn`` is given. Respects ``all_errors``. This is called 495 | when loading a pyproject.toml, and when making metadata. Checks: 496 | 497 | - ``metadata_version`` is a known version or None 498 | - ``name`` is a valid project name 499 | - ``license_files`` can't be used with classic ``license`` 500 | - License classifiers can't be used with SPDX license 501 | - ``description`` is a single line (warning) 502 | - ``license`` is not an SPDX license expression if metadata_version >= 2.4 (warning) 503 | - License classifiers deprecated for metadata_version >= 2.4 (warning) 504 | - ``license`` is an SPDX license expression if metadata_version >= 2.4 505 | - ``license_files`` is supported only for metadata_version >= 2.4 506 | - ``project_url`` can't contain keys over 32 characters 507 | """ 508 | errors = ErrorCollector(collect_errors=self.all_errors) 509 | 510 | if self.auto_metadata_version not in constants.KNOWN_METADATA_VERSIONS: 511 | msg = "The metadata_version must be one of {versions} or None (default)" 512 | errors.config_error(msg, versions=constants.KNOWN_METADATA_VERSIONS) 513 | 514 | try: 515 | packaging.utils.canonicalize_name(self.name, validate=True) 516 | except packaging.utils.InvalidName: 517 | msg = ( 518 | "Invalid project name {name!r}. A valid name consists only of ASCII letters and " 519 | "numbers, period, underscore and hyphen. It must start and end with a letter or number" 520 | ) 521 | errors.config_error(msg, key="project.name", name=self.name) 522 | 523 | if self.license_files is not None and isinstance(self.license, License): 524 | msg = '{key} must not be used when "project.license" is not a SPDX license expression' 525 | errors.config_error(msg, key="project.license-files") 526 | 527 | if isinstance(self.license, str) and any( 528 | c.startswith("License ::") for c in self.classifiers 529 | ): 530 | msg = "Setting {key} to an SPDX license expression is not compatible with 'License ::' classifiers" 531 | errors.config_error(msg, key="project.license") 532 | 533 | if warn: 534 | if self.description and "\n" in self.description: 535 | warnings.warn( 536 | 'The one-line summary "project.description" should not contain more than one line. Readers might merge or truncate newlines.', 537 | ConfigurationWarning, 538 | stacklevel=2, 539 | ) 540 | if self.auto_metadata_version not in constants.PRE_SPDX_METADATA_VERSIONS: 541 | if isinstance(self.license, License): 542 | warnings.warn( 543 | 'Set "project.license" to an SPDX license expression for metadata >= 2.4', 544 | ConfigurationWarning, 545 | stacklevel=2, 546 | ) 547 | elif any(c.startswith("License ::") for c in self.classifiers): 548 | warnings.warn( 549 | "'License ::' classifiers are deprecated for metadata >= 2.4, use a SPDX license expression for \"project.license\" instead", 550 | ConfigurationWarning, 551 | stacklevel=2, 552 | ) 553 | 554 | if ( 555 | isinstance(self.license, str) 556 | and self.auto_metadata_version in constants.PRE_SPDX_METADATA_VERSIONS 557 | ): 558 | msg = "Setting {key} to an SPDX license expression is supported only when emitting metadata version >= 2.4" 559 | errors.config_error(msg, key="project.license") 560 | 561 | if ( 562 | self.license_files is not None 563 | and self.auto_metadata_version in constants.PRE_SPDX_METADATA_VERSIONS 564 | ): 565 | msg = "{key} is supported only when emitting metadata version >= 2.4" 566 | errors.config_error(msg, key="project.license-files") 567 | 568 | for name in self.urls: 569 | if len(name) > 32: 570 | msg = "{key} names cannot be more than 32 characters long" 571 | errors.config_error(msg, key="project.urls", got=name) 572 | 573 | errors.finalize("Metadata validation failed") 574 | 575 | def _write_metadata( # noqa: C901 576 | self, smart_message: _SmartMessageSetter | _JSonMessageSetter 577 | ) -> None: 578 | """ 579 | Write the metadata to the message. Handles JSON or Message. 580 | """ 581 | errors = ErrorCollector(collect_errors=self.all_errors) 582 | with errors.collect(): 583 | self.validate(warn=False) 584 | 585 | smart_message["Metadata-Version"] = self.auto_metadata_version 586 | smart_message["Name"] = self.name 587 | if not self.version: 588 | msg = "Field {key} missing" 589 | errors.config_error(msg, key="project.version") 590 | smart_message["Version"] = str(self.version) 591 | # skip 'Platform' 592 | # skip 'Supported-Platform' 593 | if self.description: 594 | smart_message["Summary"] = self.description 595 | smart_message["Keywords"] = ",".join(self.keywords) or None 596 | # skip 'Home-page' 597 | # skip 'Download-URL' 598 | smart_message["Author"] = _name_list(self.authors) 599 | smart_message["Author-Email"] = _email_list(self.authors) 600 | smart_message["Maintainer"] = _name_list(self.maintainers) 601 | smart_message["Maintainer-Email"] = _email_list(self.maintainers) 602 | 603 | if isinstance(self.license, License): 604 | smart_message["License"] = self.license.text 605 | elif isinstance(self.license, str): 606 | smart_message["License-Expression"] = self.license 607 | 608 | if self.license_files is not None: 609 | for license_file in sorted(set(self.license_files)): 610 | smart_message["License-File"] = os.fspath(license_file.as_posix()) 611 | elif ( 612 | self.auto_metadata_version not in constants.PRE_SPDX_METADATA_VERSIONS 613 | and isinstance(self.license, License) 614 | and self.license.file 615 | ): 616 | smart_message["License-File"] = os.fspath(self.license.file.as_posix()) 617 | 618 | for classifier in self.classifiers: 619 | smart_message["Classifier"] = classifier 620 | # skip 'Provides-Dist' 621 | # skip 'Obsoletes-Dist' 622 | # skip 'Requires-External' 623 | for name, url in self.urls.items(): 624 | smart_message["Project-URL"] = f"{name}, {url}" 625 | if self.requires_python: 626 | smart_message["Requires-Python"] = str(self.requires_python) 627 | for dep in self.dependencies: 628 | smart_message["Requires-Dist"] = str(dep) 629 | for extra, requirements in self.optional_dependencies.items(): 630 | norm_extra = extra.replace(".", "-").replace("_", "-").lower() 631 | smart_message["Provides-Extra"] = norm_extra 632 | for requirement in requirements: 633 | smart_message["Requires-Dist"] = str( 634 | _build_extra_req(norm_extra, requirement) 635 | ) 636 | if self.readme: 637 | if self.readme.content_type: 638 | smart_message["Description-Content-Type"] = self.readme.content_type 639 | smart_message.set_payload(self.readme.text) 640 | # Core Metadata 2.2 641 | if self.auto_metadata_version != "2.1": 642 | for field in self.dynamic_metadata: 643 | if field.lower() in {"name", "version", "dynamic"}: 644 | msg = f"Metadata field {field!r} cannot be declared dynamic" 645 | errors.config_error(msg) 646 | if field.lower() not in constants.KNOWN_METADATA_FIELDS: 647 | msg = f"Unknown metadata field {field!r} cannot be declared dynamic" 648 | errors.config_error(msg) 649 | smart_message["Dynamic"] = field 650 | 651 | errors.finalize("Failed to write metadata") 652 | 653 | 654 | def _name_list(people: list[tuple[str, str | None]]) -> str | None: 655 | """ 656 | Build a comma-separated list of names. 657 | """ 658 | return ", ".join(name for name, email_ in people if not email_) or None 659 | 660 | 661 | def _email_list(people: list[tuple[str, str | None]]) -> str | None: 662 | """ 663 | Build a comma-separated list of emails. 664 | """ 665 | return ( 666 | ", ".join( 667 | email.utils.formataddr((name, _email)) for name, _email in people if _email 668 | ) 669 | or None 670 | ) 671 | 672 | 673 | def _build_extra_req( 674 | extra: str, 675 | requirement: Requirement, 676 | ) -> Requirement: 677 | """ 678 | Build a new requirement with an extra marker. 679 | """ 680 | requirement = copy.copy(requirement) 681 | if requirement.marker: 682 | if "or" in requirement.marker._markers: 683 | requirement.marker = packaging.markers.Marker( 684 | f"({requirement.marker}) and extra == {extra!r}" 685 | ) 686 | else: 687 | requirement.marker = packaging.markers.Marker( 688 | f"{requirement.marker} and extra == {extra!r}" 689 | ) 690 | else: 691 | requirement.marker = packaging.markers.Marker(f"extra == {extra!r}") 692 | return requirement 693 | -------------------------------------------------------------------------------- /pyproject_metadata/constants.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | """ 4 | Constants for the pyproject_metadata package, collected here to make them easy 5 | to update. These should be considered mostly private. 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | __all__ = [ 11 | "KNOWN_BUILD_SYSTEM_FIELDS", 12 | "KNOWN_METADATA_FIELDS", 13 | "KNOWN_METADATA_VERSIONS", 14 | "KNOWN_METADATA_VERSIONS", 15 | "KNOWN_MULTIUSE", 16 | "KNOWN_PROJECT_FIELDS", 17 | "KNOWN_TOPLEVEL_FIELDS", 18 | "PRE_SPDX_METADATA_VERSIONS", 19 | "PROJECT_TO_METADATA", 20 | ] 21 | 22 | 23 | def __dir__() -> list[str]: 24 | return __all__ 25 | 26 | 27 | KNOWN_METADATA_VERSIONS = {"2.1", "2.2", "2.3", "2.4"} 28 | PRE_SPDX_METADATA_VERSIONS = {"2.1", "2.2", "2.3"} 29 | 30 | PROJECT_TO_METADATA = { 31 | "authors": frozenset(["Author", "Author-Email"]), 32 | "classifiers": frozenset(["Classifier"]), 33 | "dependencies": frozenset(["Requires-Dist"]), 34 | "description": frozenset(["Summary"]), 35 | "dynamic": frozenset(), 36 | "entry-points": frozenset(), 37 | "gui-scripts": frozenset(), 38 | "keywords": frozenset(["Keywords"]), 39 | "license": frozenset(["License", "License-Expression"]), 40 | "license-files": frozenset(["License-File"]), 41 | "maintainers": frozenset(["Maintainer", "Maintainer-Email"]), 42 | "name": frozenset(["Name"]), 43 | "optional-dependencies": frozenset(["Provides-Extra", "Requires-Dist"]), 44 | "readme": frozenset(["Description", "Description-Content-Type"]), 45 | "requires-python": frozenset(["Requires-Python"]), 46 | "scripts": frozenset(), 47 | "urls": frozenset(["Project-URL"]), 48 | "version": frozenset(["Version"]), 49 | } 50 | 51 | KNOWN_TOPLEVEL_FIELDS = {"build-system", "project", "tool", "dependency-groups"} 52 | KNOWN_BUILD_SYSTEM_FIELDS = {"backend-path", "build-backend", "requires"} 53 | KNOWN_PROJECT_FIELDS = set(PROJECT_TO_METADATA) 54 | 55 | KNOWN_METADATA_FIELDS = { 56 | "author", 57 | "author-email", 58 | "classifier", 59 | "description", 60 | "description-content-type", 61 | "download-url", # Not specified via pyproject standards, deprecated by PEP 753 62 | "dynamic", # Can't be in dynamic 63 | "home-page", # Not specified via pyproject standards, deprecated by PEP 753 64 | "keywords", 65 | "license", 66 | "license-expression", 67 | "license-file", 68 | "maintainer", 69 | "maintainer-email", 70 | "metadata-version", 71 | "name", # Can't be in dynamic 72 | "obsoletes", # Deprecated 73 | "obsoletes-dist", # Rarely used 74 | "platform", # Not specified via pyproject standards 75 | "project-url", 76 | "provides", # Deprecated 77 | "provides-dist", # Rarely used 78 | "provides-extra", 79 | "requires", # Deprecated 80 | "requires-dist", 81 | "requires-external", # Not specified via pyproject standards 82 | "requires-python", 83 | "summary", 84 | "supported-platform", # Not specified via pyproject standards 85 | "version", # Can't be in dynamic 86 | } 87 | 88 | KNOWN_MULTIUSE = { 89 | "dynamic", 90 | "platform", 91 | "provides-extra", 92 | "supported-platform", 93 | "license-file", 94 | "classifier", 95 | "requires-dist", 96 | "requires-external", 97 | "project-url", 98 | "provides-dist", 99 | "obsoletes-dist", 100 | "requires", # Deprecated 101 | "obsoletes", # Deprecated 102 | "provides", # Deprecated 103 | } 104 | -------------------------------------------------------------------------------- /pyproject_metadata/errors.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | """ 4 | This module defines exceptions and error handling utilities. It is the 5 | recommend path to access ``ConfiguratonError``, ``ConfigurationWarning``, and 6 | ``ExceptionGroup``. For backward compatibility, ``ConfigurationError`` is 7 | re-exported in the top-level package. 8 | """ 9 | 10 | from __future__ import annotations 11 | 12 | import builtins 13 | import contextlib 14 | import dataclasses 15 | import sys 16 | import typing 17 | import warnings 18 | 19 | __all__ = [ 20 | "ConfigurationError", 21 | "ConfigurationWarning", 22 | "ExceptionGroup", 23 | ] 24 | 25 | 26 | def __dir__() -> list[str]: 27 | return __all__ 28 | 29 | 30 | class ConfigurationError(Exception): 31 | """Error in the backend metadata. Has an optional key attribute, which will be non-None 32 | if the error is related to a single key in the pyproject.toml file.""" 33 | 34 | def __init__(self, msg: str, *, key: str | None = None): 35 | super().__init__(msg) 36 | self._key = key 37 | 38 | @property 39 | def key(self) -> str | None: # pragma: no cover 40 | return self._key 41 | 42 | 43 | class ConfigurationWarning(UserWarning): 44 | """Warnings about backend metadata.""" 45 | 46 | 47 | if sys.version_info >= (3, 11): 48 | ExceptionGroup = builtins.ExceptionGroup 49 | else: 50 | 51 | class ExceptionGroup(Exception): 52 | """A minimal implementation of `ExceptionGroup` from Python 3.11. 53 | 54 | Users can replace this with a more complete implementation, such as from 55 | the exceptiongroup backport package, if better error messages and 56 | integration with tooling is desired and the addition of a dependency is 57 | acceptable. 58 | """ 59 | 60 | message: str 61 | exceptions: list[Exception] 62 | 63 | def __init__(self, message: str, exceptions: list[Exception]) -> None: 64 | self.message = message 65 | self.exceptions = exceptions 66 | 67 | def __repr__(self) -> str: 68 | return f"{self.__class__.__name__}({self.message!r}, {self.exceptions!r})" 69 | 70 | 71 | @dataclasses.dataclass 72 | class ErrorCollector: 73 | """ 74 | Collect errors and raise them as a group at the end (if collect_errors is True), 75 | otherwise raise them immediately. 76 | """ 77 | 78 | collect_errors: bool 79 | errors: list[Exception] = dataclasses.field(default_factory=list) 80 | 81 | def config_error( 82 | self, 83 | msg: str, 84 | *, 85 | key: str | None = None, 86 | got: typing.Any = None, 87 | got_type: type[typing.Any] | None = None, 88 | warn: bool = False, 89 | **kwargs: typing.Any, 90 | ) -> None: 91 | """Raise a configuration error, or add it to the error list.""" 92 | msg = msg.format(key=f'"{key}"', **kwargs) 93 | if got is not None: 94 | msg = f"{msg} (got {got!r})" 95 | if got_type is not None: 96 | msg = f"{msg} (got {got_type.__name__})" 97 | 98 | if warn: 99 | warnings.warn(msg, ConfigurationWarning, stacklevel=3) 100 | elif self.collect_errors: 101 | self.errors.append(ConfigurationError(msg, key=key)) 102 | else: 103 | raise ConfigurationError(msg, key=key) 104 | 105 | def finalize(self, msg: str) -> None: 106 | """Raise a group exception if there are any errors.""" 107 | if self.errors: 108 | raise ExceptionGroup(msg, self.errors) 109 | 110 | @contextlib.contextmanager 111 | def collect(self) -> typing.Generator[None, None, None]: 112 | """Support nesting; add any grouped errors to the error list.""" 113 | if self.collect_errors: 114 | try: 115 | yield 116 | except ExceptionGroup as error: 117 | self.errors.extend(error.exceptions) 118 | else: 119 | yield 120 | -------------------------------------------------------------------------------- /pyproject_metadata/project_table.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | """ 4 | This module contains type definitions for the tables used in the 5 | ``pyproject.toml``. You should either import this at type-check time only, or 6 | make sure ``typing_extensions`` is available for Python 3.10 and below. 7 | 8 | Documentation notice: the fields with hyphens are not shown due to a sphinx-autodoc bug. 9 | """ 10 | 11 | from __future__ import annotations 12 | 13 | import sys 14 | import typing 15 | from typing import Any, Dict, List, Union 16 | 17 | if sys.version_info < (3, 11): # pragma: nocover 18 | from typing_extensions import Required 19 | else: 20 | from typing import Required 21 | 22 | from typing import Literal, TypedDict 23 | 24 | __all__ = [ 25 | "BuildSystemTable", 26 | "ContactTable", 27 | "Dynamic", 28 | "IncludeGroupTable", 29 | "LicenseTable", 30 | "ProjectTable", 31 | "PyProjectTable", 32 | "ReadmeTable", 33 | ] 34 | 35 | 36 | def __dir__() -> list[str]: 37 | return __all__ 38 | 39 | 40 | class ContactTable(TypedDict, total=False): 41 | name: str 42 | email: str 43 | 44 | 45 | class LicenseTable(TypedDict, total=False): 46 | text: str 47 | file: str 48 | 49 | 50 | ReadmeTable = TypedDict( 51 | "ReadmeTable", {"file": str, "text": str, "content-type": str}, total=False 52 | ) 53 | 54 | Dynamic = Literal[ 55 | "authors", 56 | "classifiers", 57 | "dependencies", 58 | "description", 59 | "dynamic", 60 | "entry-points", 61 | "gui-scripts", 62 | "keywords", 63 | "license", 64 | "maintainers", 65 | "optional-dependencies", 66 | "readme", 67 | "requires-python", 68 | "scripts", 69 | "urls", 70 | "version", 71 | ] 72 | 73 | ProjectTable = TypedDict( 74 | "ProjectTable", 75 | { 76 | "name": Required[str], 77 | "version": str, 78 | "description": str, 79 | "license": Union[LicenseTable, str], 80 | "license-files": List[str], 81 | "readme": Union[str, ReadmeTable], 82 | "requires-python": str, 83 | "dependencies": List[str], 84 | "optional-dependencies": Dict[str, List[str]], 85 | "entry-points": Dict[str, Dict[str, str]], 86 | "authors": List[ContactTable], 87 | "maintainers": List[ContactTable], 88 | "urls": Dict[str, str], 89 | "classifiers": List[str], 90 | "keywords": List[str], 91 | "scripts": Dict[str, str], 92 | "gui-scripts": Dict[str, str], 93 | "dynamic": List[Dynamic], 94 | }, 95 | total=False, 96 | ) 97 | 98 | BuildSystemTable = TypedDict( 99 | "BuildSystemTable", 100 | { 101 | "build-backend": str, 102 | "requires": List[str], 103 | "backend-path": List[str], 104 | }, 105 | total=False, 106 | ) 107 | 108 | # total=False here because this could be 109 | # extended in the future 110 | IncludeGroupTable = TypedDict( 111 | "IncludeGroupTable", 112 | {"include-group": str}, 113 | total=False, 114 | ) 115 | 116 | PyProjectTable = TypedDict( 117 | "PyProjectTable", 118 | { 119 | "build-system": BuildSystemTable, 120 | "project": ProjectTable, 121 | "tool": Dict[str, Any], 122 | "dependency-groups": Dict[str, List[Union[str, IncludeGroupTable]]], 123 | }, 124 | total=False, 125 | ) 126 | 127 | # Tests for type checking 128 | if typing.TYPE_CHECKING: 129 | PyProjectTable( 130 | { 131 | "build-system": BuildSystemTable( 132 | {"build-backend": "one", "requires": ["two"]} 133 | ), 134 | "project": ProjectTable( 135 | { 136 | "name": "one", 137 | "version": "0.1.0", 138 | } 139 | ), 140 | "tool": {"thing": object()}, 141 | "dependency-groups": { 142 | "one": [ 143 | "one", 144 | IncludeGroupTable({"include-group": "two"}), 145 | ] 146 | }, 147 | } 148 | ) 149 | -------------------------------------------------------------------------------- /pyproject_metadata/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/pyproject-metadata/7c6a3aa61ff34b421e3210cf60c97366953168ab/pyproject_metadata/py.typed -------------------------------------------------------------------------------- /pyproject_metadata/pyproject.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | """ 4 | This module focues on reading pyproject.toml fields with error collection. It is 5 | mostly internal, except for License and Readme classes, which are re-exported in 6 | the top-level package. 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | import dataclasses 12 | import pathlib 13 | import re 14 | import typing 15 | 16 | import packaging.requirements 17 | 18 | from .errors import ErrorCollector 19 | 20 | if typing.TYPE_CHECKING: 21 | from collections.abc import Generator, Iterable, Sequence 22 | 23 | from packaging.requirements import Requirement 24 | 25 | from .project_table import ContactTable, Dynamic, ProjectTable 26 | 27 | 28 | __all__ = [ 29 | "License", 30 | "Readme", 31 | ] 32 | 33 | 34 | def __dir__() -> list[str]: 35 | return __all__ 36 | 37 | 38 | @dataclasses.dataclass(frozen=True) 39 | class License: 40 | """ 41 | This represents a classic license, which contains text, and optionally a 42 | file path. Modern licenses are just SPDX identifiers, which are strings. 43 | """ 44 | 45 | text: str 46 | file: pathlib.Path | None 47 | 48 | 49 | @dataclasses.dataclass(frozen=True) 50 | class Readme: 51 | """ 52 | This represents a readme, which contains text and a content type, and 53 | optionally a file path. 54 | """ 55 | 56 | text: str 57 | file: pathlib.Path | None 58 | content_type: str 59 | 60 | 61 | T = typing.TypeVar("T") 62 | 63 | 64 | @dataclasses.dataclass 65 | class PyProjectReader(ErrorCollector): 66 | """Class for reading pyproject.toml fields with error collection. 67 | 68 | Unrelated errors are collected and raised at once if the `collect_errors` 69 | parameter is set to `True`. Some methods will return None if an error was 70 | raised. Most of them expect a non-None value as input to enforce the caller 71 | to handle missing vs. error correctly. The exact design is based on usage, 72 | as this is an internal class. 73 | """ 74 | 75 | def ensure_str(self, value: str, key: str) -> str | None: 76 | """Ensure that a value is a string.""" 77 | if isinstance(value, str): 78 | return value 79 | 80 | msg = "Field {key} has an invalid type, expecting a string" 81 | self.config_error(msg, key=key, got_type=type(value)) 82 | return None 83 | 84 | def ensure_list(self, val: list[T], key: str) -> list[T] | None: 85 | """Ensure that a value is a list of strings.""" 86 | if not isinstance(val, list): 87 | msg = "Field {key} has an invalid type, expecting a list of strings" 88 | self.config_error(msg, key=key, got_type=type(val)) 89 | return None 90 | for item in val: 91 | if not isinstance(item, str): 92 | msg = "Field {key} contains item with invalid type, expecting a string" 93 | self.config_error(msg, key=key, got_type=type(item)) 94 | return None 95 | 96 | return val 97 | 98 | def ensure_dict(self, val: dict[str, str], key: str) -> dict[str, str] | None: 99 | """Ensure that a value is a dictionary of strings.""" 100 | if not isinstance(val, dict): 101 | msg = "Field {key} has an invalid type, expecting a table of strings" 102 | self.config_error(msg, key=key, got_type=type(val)) 103 | return None 104 | for subkey, item in val.items(): 105 | if not isinstance(item, str): 106 | msg = "Field {key} has an invalid type, expecting a string" 107 | self.config_error(msg, key=f"{key}.{subkey}", got_type=type(item)) 108 | return None 109 | return val 110 | 111 | def ensure_people( 112 | self, val: Sequence[ContactTable], key: str 113 | ) -> list[tuple[str, str | None]]: 114 | """Ensure that a value is a list of tables with optional "name" and "email" keys.""" 115 | if not isinstance(val, list): 116 | msg = ( 117 | "Field {key} has an invalid type, expecting a list of " 118 | 'tables containing the "name" and/or "email" keys' 119 | ) 120 | self.config_error(msg, key=key, got_type=type(val)) 121 | return [] 122 | for each in val: 123 | if not isinstance(each, dict): 124 | msg = ( 125 | "Field {key} has an invalid type, expecting a list of " 126 | 'tables containing the "name" and/or "email" keys' 127 | " (got list with {type_name})" 128 | ) 129 | self.config_error(msg, key=key, type_name=type(each).__name__) 130 | return [] 131 | for value in each.values(): 132 | if not isinstance(value, str): 133 | msg = ( 134 | "Field {key} has an invalid type, expecting a list of " 135 | 'tables containing the "name" and/or "email" keys' 136 | " (got list with dict with {type_name})" 137 | ) 138 | self.config_error(msg, key=key, type_name=type(value).__name__) 139 | return [] 140 | extra_keys = set(each) - {"name", "email"} 141 | if extra_keys: 142 | msg = ( 143 | "Field {key} has an invalid type, expecting a list of " 144 | 'tables containing the "name" and/or "email" keys' 145 | " (got list with dict with extra keys {extra_keys})" 146 | ) 147 | self.config_error( 148 | msg, 149 | key=key, 150 | extra_keys=", ".join(sorted(f'"{k}"' for k in extra_keys)), 151 | ) 152 | return [] 153 | return [(entry.get("name", "Unknown"), entry.get("email")) for entry in val] 154 | 155 | def get_license( 156 | self, project: ProjectTable, project_dir: pathlib.Path 157 | ) -> License | str | None: 158 | """Get the license field from the project table. Handles PEP 639 style license too. 159 | 160 | None is returned if the license field is not present or if an error occurred. 161 | """ 162 | val = project.get("license") 163 | if val is None: 164 | return None 165 | if isinstance(val, str): 166 | return val 167 | 168 | if isinstance(val, dict): 169 | _license = self.ensure_dict(val, "project.license") # type: ignore[arg-type] 170 | if _license is None: 171 | return None 172 | else: 173 | msg = "Field {key} has an invalid type, expecting a string or table of strings" 174 | self.config_error(msg, key="project.license", got_type=type(val)) 175 | return None 176 | 177 | for field in _license: 178 | if field not in ("file", "text"): 179 | msg = "Unexpected field {key}" 180 | self.config_error(msg, key=f"project.license.{field}") 181 | return None 182 | 183 | file: pathlib.Path | None = None 184 | filename = _license.get("file") 185 | text = _license.get("text") 186 | 187 | if (filename and text) or (not filename and not text): 188 | msg = ( 189 | 'Invalid {key} contents, expecting a string or one key "file" or "text"' 190 | ) 191 | self.config_error(msg, key="project.license", got=_license) 192 | return None 193 | 194 | if filename: 195 | file = project_dir.joinpath(filename) 196 | if not file.is_file(): 197 | msg = f"License file not found ({filename!r})" 198 | self.config_error(msg, key="project.license.file") 199 | return None 200 | text = file.read_text(encoding="utf-8") 201 | 202 | assert text is not None 203 | return License(text, file) 204 | 205 | def get_license_files( 206 | self, project: ProjectTable, project_dir: pathlib.Path 207 | ) -> list[pathlib.Path] | None: 208 | """Get the license-files list of files from the project table. 209 | 210 | Returns None if an error occurred (including invalid globs, etc) or if 211 | not present. 212 | """ 213 | license_files = project.get("license-files") 214 | if license_files is None: 215 | return None 216 | if self.ensure_list(license_files, "project.license-files") is None: 217 | return None 218 | 219 | return list(self._get_files_from_globs(project_dir, license_files)) 220 | 221 | def get_readme( # noqa: C901 222 | self, project: ProjectTable, project_dir: pathlib.Path 223 | ) -> Readme | None: 224 | """Get the text of the readme from the project table. 225 | 226 | Returns None if an error occurred or if the readme field is not present. 227 | """ 228 | if "readme" not in project: 229 | return None 230 | 231 | filename: str | None = None 232 | file: pathlib.Path | None = None 233 | text: str | None = None 234 | content_type: str | None = None 235 | 236 | readme = project["readme"] 237 | if isinstance(readme, str): 238 | # readme is a file 239 | text = None 240 | filename = readme 241 | if filename.endswith(".md"): 242 | content_type = "text/markdown" 243 | elif filename.endswith(".rst"): 244 | content_type = "text/x-rst" 245 | else: 246 | msg = "Could not infer content type for readme file {filename!r}" 247 | self.config_error(msg, key="project.readme", filename=filename) 248 | return None 249 | elif isinstance(readme, dict): 250 | # readme is a dict containing either 'file' or 'text', and content-type 251 | for field in readme: 252 | if field not in ("content-type", "file", "text"): 253 | msg = "Unexpected field {key}" 254 | self.config_error(msg, key=f"project.readme.{field}") 255 | return None 256 | 257 | content_type_raw = readme.get("content-type") 258 | if content_type_raw is not None: 259 | content_type = self.ensure_str( 260 | content_type_raw, "project.readme.content-type" 261 | ) 262 | if content_type is None: 263 | return None 264 | filename_raw = readme.get("file") 265 | if filename_raw is not None: 266 | filename = self.ensure_str(filename_raw, "project.readme.file") 267 | if filename is None: 268 | return None 269 | 270 | text_raw = readme.get("text") 271 | if text_raw is not None: 272 | text = self.ensure_str(text_raw, "project.readme.text") 273 | if text is None: 274 | return None 275 | 276 | if (filename and text) or (not filename and not text): 277 | msg = 'Invalid {key} contents, expecting either "file" or "text"' 278 | self.config_error(msg, key="project.readme", got=readme) 279 | return None 280 | if not content_type: 281 | msg = "Field {key} missing" 282 | self.config_error(msg, key="project.readme.content-type") 283 | return None 284 | else: 285 | msg = "Field {key} has an invalid type, expecting either a string or table of strings" 286 | self.config_error(msg, key="project.readme", got_type=type(readme)) 287 | return None 288 | 289 | if filename: 290 | file = project_dir.joinpath(filename) 291 | if not file.is_file(): 292 | msg = "Readme file not found ({filename!r})" 293 | self.config_error(msg, key="project.readme.file", filename=filename) 294 | return None 295 | text = file.read_text(encoding="utf-8") 296 | 297 | assert text is not None 298 | return Readme(text, file, content_type) 299 | 300 | def get_dependencies(self, project: ProjectTable) -> list[Requirement]: 301 | """Get the dependencies from the project table.""" 302 | 303 | requirement_strings: list[str] | None = None 304 | requirement_strings_raw = project.get("dependencies") 305 | if requirement_strings_raw is not None: 306 | requirement_strings = self.ensure_list( 307 | requirement_strings_raw, "project.dependencies" 308 | ) 309 | if requirement_strings is None: 310 | return [] 311 | 312 | requirements: list[Requirement] = [] 313 | for req in requirement_strings: 314 | try: 315 | requirements.append(packaging.requirements.Requirement(req)) 316 | except packaging.requirements.InvalidRequirement as e: 317 | msg = "Field {key} contains an invalid PEP 508 requirement string {req!r} ({error!r})" 318 | self.config_error(msg, key="project.dependencies", req=req, error=e) 319 | return [] 320 | return requirements 321 | 322 | def get_optional_dependencies( 323 | self, 324 | project: ProjectTable, 325 | ) -> dict[str, list[Requirement]]: 326 | """Get the optional dependencies from the project table.""" 327 | 328 | val = project.get("optional-dependencies") 329 | if not val: 330 | return {} 331 | 332 | requirements_dict: dict[str, list[Requirement]] = {} 333 | if not isinstance(val, dict): 334 | msg = "Field {key} has an invalid type, expecting a table of PEP 508 requirement strings" 335 | self.config_error( 336 | msg, key="project.optional-dependencies", got_type=type(val) 337 | ) 338 | return {} 339 | for extra, requirements in val.copy().items(): 340 | assert isinstance(extra, str) 341 | if not isinstance(requirements, list): 342 | msg = "Field {key} has an invalid type, expecting a table of PEP 508 requirement strings" 343 | self.config_error( 344 | msg, 345 | key=f"project.optional-dependencies.{extra}", 346 | got_type=type(requirements), 347 | ) 348 | return {} 349 | requirements_dict[extra] = [] 350 | for req in requirements: 351 | if not isinstance(req, str): 352 | msg = "Field {key} has an invalid type, expecting a PEP 508 requirement string" 353 | self.config_error( 354 | msg, 355 | key=f"project.optional-dependencies.{extra}", 356 | got_type=type(req), 357 | ) 358 | return {} 359 | try: 360 | requirements_dict[extra].append( 361 | packaging.requirements.Requirement(req) 362 | ) 363 | except packaging.requirements.InvalidRequirement as e: 364 | msg = ( 365 | "Field {key} contains " 366 | "an invalid PEP 508 requirement string {req!r} ({error!r})" 367 | ) 368 | self.config_error( 369 | msg, 370 | key=f"project.optional-dependencies.{extra}", 371 | req=req, 372 | error=e, 373 | ) 374 | return {} 375 | return dict(requirements_dict) 376 | 377 | def get_entrypoints(self, project: ProjectTable) -> dict[str, dict[str, str]]: 378 | """Get the entrypoints from the project table.""" 379 | 380 | val = project.get("entry-points", None) 381 | if val is None: 382 | return {} 383 | if not isinstance(val, dict): 384 | msg = "Field {key} has an invalid type, expecting a table of entrypoint sections" 385 | self.config_error(msg, key="project.entry-points", got_type=type(val)) 386 | return {} 387 | for section, entrypoints in val.items(): 388 | assert isinstance(section, str) 389 | if not re.match(r"^\w+(\.\w+)*$", section): 390 | msg = ( 391 | "Field {key} has an invalid value, expecting a name " 392 | "containing only alphanumeric, underscore, or dot characters" 393 | ) 394 | self.config_error(msg, key="project.entry-points", got=section) 395 | return {} 396 | if not isinstance(entrypoints, dict): 397 | msg = ( 398 | "Field {key} has an invalid type, expecting a table of entrypoints" 399 | ) 400 | self.config_error( 401 | msg, 402 | key=f"project.entry-points.{section}", 403 | got_type=type(entrypoints), 404 | ) 405 | return {} 406 | for name, entrypoint in entrypoints.items(): 407 | assert isinstance(name, str) 408 | if not isinstance(entrypoint, str): 409 | msg = "Field {key} has an invalid type, expecting a string" 410 | self.config_error( 411 | msg, 412 | key=f"project.entry-points.{section}.{name}", 413 | got_type=type(entrypoint), 414 | ) 415 | return {} 416 | return val 417 | 418 | def get_dynamic(self, project: ProjectTable) -> list[Dynamic]: 419 | """Get the dynamic fields from the project table. 420 | 421 | Returns an empty list if the field is not present or if an error occurred. 422 | """ 423 | dynamic = project.get("dynamic", []) 424 | 425 | self.ensure_list(dynamic, "project.dynamic") 426 | 427 | if "name" in dynamic: 428 | msg = "Unsupported field 'name' in {key}" 429 | self.config_error(msg, key="project.dynamic") 430 | return [] 431 | 432 | return dynamic 433 | 434 | def _get_files_from_globs( 435 | self, project_dir: pathlib.Path, globs: Iterable[str] 436 | ) -> Generator[pathlib.Path, None, None]: 437 | """Given a list of globs, get files that match.""" 438 | 439 | for glob in globs: 440 | if glob.startswith(("..", "/")): 441 | msg = "{glob!r} is an invalid {key} glob: the pattern must match files within the project directory" 442 | self.config_error(msg, key="project.license-files", glob=glob) 443 | break 444 | files = [f for f in project_dir.glob(glob) if f.is_file()] 445 | if not files: 446 | msg = "Every pattern in {key} must match at least one file: {glob!r} did not match any" 447 | self.config_error(msg, key="project.license-files", glob=glob) 448 | break 449 | for f in files: 450 | yield f.relative_to(project_dir) 451 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/pyproject-metadata/7c6a3aa61ff34b421e3210cf60c97366953168ab/tests/__init__.py -------------------------------------------------------------------------------- /tests/packages/broken_license/LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc. 5 | 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Library General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS -------------------------------------------------------------------------------- /tests/packages/dynamic-description/dynamic_description.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/pyproject-metadata/7c6a3aa61ff34b421e3210cf60c97366953168ab/tests/packages/dynamic-description/dynamic_description.py -------------------------------------------------------------------------------- /tests/packages/dynamic-description/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = 'dynamic-description' 3 | version = '1.0.0' 4 | dynamic = [ 5 | 'description', 6 | ] 7 | -------------------------------------------------------------------------------- /tests/packages/full-metadata/README.md: -------------------------------------------------------------------------------- 1 | some readme 👋 2 | -------------------------------------------------------------------------------- /tests/packages/full-metadata/full_metadata.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/pyproject-metadata/7c6a3aa61ff34b421e3210cf60c97366953168ab/tests/packages/full-metadata/full_metadata.py -------------------------------------------------------------------------------- /tests/packages/full-metadata/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = 'full_metadata' 3 | version = '3.2.1' 4 | description = 'A package with all the metadata :)' 5 | readme = 'README.md' 6 | license = { text = 'some license text' } 7 | keywords = ['trampolim', 'is', 'interesting'] 8 | authors = [ 9 | { email = 'example@example.com' }, 10 | { name = 'Example!' }, 11 | ] 12 | maintainers = [ 13 | { name = 'Other Example', email = 'other@example.com' }, 14 | ] 15 | classifiers = [ 16 | 'Development Status :: 4 - Beta', 17 | 'Programming Language :: Python', 18 | ] 19 | 20 | requires-python = '>=3.8' 21 | dependencies = [ 22 | 'dependency1', 23 | 'dependency2>1.0.0', 24 | 'dependency3[extra]', 25 | 'dependency4; os_name != "nt"', 26 | 'dependency5[other-extra]>1.0; os_name == "nt"', 27 | ] 28 | 29 | [project.optional-dependencies] 30 | test = [ 31 | 'test_dependency', 32 | 'test_dependency[test_extra]', 33 | 'test_dependency[test_extra2] > 3.0; os_name == "nt"', 34 | ] 35 | 36 | [project.urls] 37 | homepage = 'example.com' 38 | documentation = 'readthedocs.org' 39 | repository = 'github.com/some/repo' 40 | changelog = 'github.com/some/repo/blob/master/CHANGELOG.rst' 41 | 42 | [project.scripts] 43 | full-metadata = 'full_metadata:main_cli' 44 | 45 | [project.gui-scripts] 46 | full-metadata-gui = 'full_metadata:main_gui' 47 | 48 | [project.entry-points.custom] 49 | full-metadata = 'full_metadata:main_custom' 50 | -------------------------------------------------------------------------------- /tests/packages/full-metadata2/LICENSE: -------------------------------------------------------------------------------- 1 | Some license! 👋 2 | -------------------------------------------------------------------------------- /tests/packages/full-metadata2/README.rst: -------------------------------------------------------------------------------- 1 | some readme 👋 2 | -------------------------------------------------------------------------------- /tests/packages/full-metadata2/full_metadata2.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/pyproject-metadata/7c6a3aa61ff34b421e3210cf60c97366953168ab/tests/packages/full-metadata2/full_metadata2.py -------------------------------------------------------------------------------- /tests/packages/full-metadata2/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = 'full-metadata2' 3 | version = '3.2.1' 4 | description = 'A package with all the metadata :)' 5 | readme = 'README.rst' 6 | license = { file = 'LICENSE' } 7 | keywords = ['trampolim', 'is', 'interesting'] 8 | authors = [ 9 | { email = 'example@example.com' }, 10 | { name = 'Example!' }, 11 | ] 12 | maintainers = [ 13 | { name = 'Other Example', email = 'other@example.com' }, 14 | ] 15 | classifiers = [ 16 | 'Development Status :: 4 - Beta', 17 | 'Programming Language :: Python', 18 | ] 19 | 20 | requires-python = '>=3.8' 21 | dependencies = [ 22 | 'dependency1', 23 | 'dependency2>1.0.0', 24 | 'dependency3[extra]', 25 | 'dependency4; os_name != "nt"', 26 | 'dependency5[other-extra]>1.0; os_name == "nt"', 27 | ] 28 | 29 | [project.optional-dependencies] 30 | test = [ 31 | 'test_dependency', 32 | 'test_dependency[test_extra]', 33 | 'test_dependency[test_extra2] > 3.0; os_name == "nt"', 34 | ] 35 | 36 | [project.urls] 37 | homepage = 'example.com' 38 | documentation = 'readthedocs.org' 39 | repository = 'github.com/some/repo' 40 | changelog = 'github.com/some/repo/blob/master/CHANGELOG.rst' 41 | 42 | [project.scripts] 43 | full-metadata = 'full_metadata:main_cli' 44 | 45 | [project.gui-scripts] 46 | full-metadata-gui = 'full_metadata:main_gui' 47 | 48 | [project.entry-points.custom] 49 | full-metadata = 'full_metadata:main_custom' 50 | -------------------------------------------------------------------------------- /tests/packages/fulltext_license/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright © 2019 Filipe Laíns 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a 4 | copy of this software and associated documentation files (the "Software"), 5 | to deal in the Software without restriction, including without limitation 6 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the 8 | Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice (including the next 11 | paragraph) shall be included in all copies or substantial portions of the 12 | Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 17 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 19 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 20 | DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /tests/packages/spdx/AUTHORS.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/pyproject-metadata/7c6a3aa61ff34b421e3210cf60c97366953168ab/tests/packages/spdx/AUTHORS.txt -------------------------------------------------------------------------------- /tests/packages/spdx/LICENSE.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/pyproject-metadata/7c6a3aa61ff34b421e3210cf60c97366953168ab/tests/packages/spdx/LICENSE.md -------------------------------------------------------------------------------- /tests/packages/spdx/LICENSE.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/pyproject-metadata/7c6a3aa61ff34b421e3210cf60c97366953168ab/tests/packages/spdx/LICENSE.txt -------------------------------------------------------------------------------- /tests/packages/spdx/licenses/LICENSE.MIT: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/pyproject-metadata/7c6a3aa61ff34b421e3210cf60c97366953168ab/tests/packages/spdx/licenses/LICENSE.MIT -------------------------------------------------------------------------------- /tests/packages/spdx/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "example" 3 | version = "1.2.3" 4 | license = "MIT OR GPL-2.0-or-later OR (FSFUL AND BSD-2-Clause)" 5 | license-files = ["LICEN[CS]E*", "AUTHORS*", "licenses/LICENSE.MIT"] 6 | -------------------------------------------------------------------------------- /tests/packages/unknown-readme-type/README.just-made-this-up-now: -------------------------------------------------------------------------------- 1 | some readme 2 | -------------------------------------------------------------------------------- /tests/packages/unknown-readme-type/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = 'unknown-readme-type' 3 | version = '1.0.0' 4 | readme = 'README.just-made-this-up-now' 5 | -------------------------------------------------------------------------------- /tests/packages/unknown-readme-type/unknown_readme_type.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/pyproject-metadata/7c6a3aa61ff34b421e3210cf60c97366953168ab/tests/packages/unknown-readme-type/unknown_readme_type.py -------------------------------------------------------------------------------- /tests/test_internals.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | 5 | import pyproject_metadata 6 | import pyproject_metadata.constants 7 | import pyproject_metadata.errors 8 | import pyproject_metadata.pyproject 9 | 10 | 11 | def test_all() -> None: 12 | assert "typing" not in dir(pyproject_metadata) 13 | assert "annotations" not in dir(pyproject_metadata.constants) 14 | assert "annotations" not in dir(pyproject_metadata.errors) 15 | assert "annotations" not in dir(pyproject_metadata.pyproject) 16 | 17 | 18 | def test_project_table_all() -> None: 19 | if sys.version_info < (3, 11): 20 | pytest.importorskip("typing_extensions") 21 | import pyproject_metadata.project_table 22 | 23 | assert "annotations" not in dir(pyproject_metadata.project_table) 24 | -------------------------------------------------------------------------------- /tests/test_rfc822.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | from __future__ import annotations 4 | 5 | import email.message 6 | import inspect 7 | import re 8 | import textwrap 9 | 10 | import pytest 11 | 12 | import pyproject_metadata 13 | import pyproject_metadata.constants 14 | 15 | 16 | @pytest.mark.parametrize( 17 | ("items", "data"), 18 | [ 19 | pytest.param( 20 | [], 21 | "", 22 | id="empty", 23 | ), 24 | pytest.param( 25 | [ 26 | ("Foo", "Bar"), 27 | ], 28 | "Foo: Bar\n", 29 | id="simple", 30 | ), 31 | pytest.param( 32 | [ 33 | ("Foo", "Bar"), 34 | ("Foo2", "Bar2"), 35 | ], 36 | """\ 37 | Foo: Bar 38 | Foo2: Bar2 39 | """, 40 | id="multiple", 41 | ), 42 | pytest.param( 43 | [ 44 | ("Foo", "Unicøde"), 45 | ], 46 | "Foo: Unicøde\n", 47 | id="unicode", 48 | ), 49 | pytest.param( 50 | [ 51 | ("Foo", "🕵️"), 52 | ], 53 | "Foo: 🕵️\n", 54 | id="emoji", 55 | ), 56 | pytest.param( 57 | [ 58 | ("Item", None), 59 | ], 60 | "", 61 | id="none", 62 | ), 63 | pytest.param( 64 | [ 65 | ("ItemA", "ValueA"), 66 | ("ItemB", "ValueB"), 67 | ("ItemC", "ValueC"), 68 | ], 69 | """\ 70 | ItemA: ValueA 71 | ItemB: ValueB 72 | ItemC: ValueC 73 | """, 74 | id="order 1", 75 | ), 76 | pytest.param( 77 | [ 78 | ("ItemB", "ValueB"), 79 | ("ItemC", "ValueC"), 80 | ("ItemA", "ValueA"), 81 | ], 82 | """\ 83 | ItemB: ValueB 84 | ItemC: ValueC 85 | ItemA: ValueA 86 | """, 87 | id="order 2", 88 | ), 89 | pytest.param( 90 | [ 91 | ("ItemA", "ValueA1"), 92 | ("ItemB", "ValueB"), 93 | ("ItemC", "ValueC"), 94 | ("ItemA", "ValueA2"), 95 | ], 96 | """\ 97 | ItemA: ValueA1 98 | ItemB: ValueB 99 | ItemC: ValueC 100 | ItemA: ValueA2 101 | """, 102 | id="multiple keys", 103 | ), 104 | pytest.param( 105 | [ 106 | ("ItemA", "ValueA"), 107 | ("ItemB", "ValueB1\nValueB2\nValueB3"), 108 | ("ItemC", "ValueC"), 109 | ], 110 | """\ 111 | ItemA: ValueA 112 | ItemB: ValueB1 113 | ValueB2 114 | ValueB3 115 | ItemC: ValueC 116 | """, 117 | id="multiline", 118 | ), 119 | ], 120 | ) 121 | def test_headers( 122 | items: list[tuple[str, None | str]], data: str, monkeypatch: pytest.MonkeyPatch 123 | ) -> None: 124 | message = pyproject_metadata.RFC822Message() 125 | smart_message = pyproject_metadata._SmartMessageSetter(message) 126 | 127 | monkeypatch.setattr( 128 | pyproject_metadata.constants, 129 | "KNOWN_METADATA_FIELDS", 130 | {x.lower() for x, _ in items}, 131 | ) 132 | 133 | for name, value in items: 134 | smart_message[name] = value 135 | 136 | data = textwrap.dedent(data) + "\n" 137 | assert str(message) == data 138 | assert bytes(message) == data.encode() 139 | 140 | assert email.message_from_string(str(message)).items() == [ 141 | (a, "\n ".join(b.splitlines())) for a, b in items if b is not None 142 | ] 143 | 144 | 145 | def test_body(monkeypatch: pytest.MonkeyPatch) -> None: 146 | monkeypatch.setattr( 147 | pyproject_metadata.constants, 148 | "KNOWN_METADATA_FIELDS", 149 | {"itema", "itemb", "itemc"}, 150 | ) 151 | message = pyproject_metadata.RFC822Message() 152 | 153 | message["ItemA"] = "ValueA" 154 | message["ItemB"] = "ValueB" 155 | message["ItemC"] = "ValueC" 156 | body = inspect.cleandoc(""" 157 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris congue semper 158 | fermentum. Nunc vitae tempor ante. Aenean aliquet posuere lacus non faucibus. 159 | In porttitor congue luctus. Vivamus eu dignissim orci. Donec egestas mi ac 160 | ipsum volutpat, vel elementum sapien consectetur. Praesent dictum finibus 161 | fringilla. Sed vel feugiat leo. Nulla a pharetra augue, at tristique metus. 162 | 163 | Aliquam fermentum elit at risus sagittis, vel pretium augue congue. Donec leo 164 | risus, faucibus vel posuere efficitur, feugiat ut leo. Aliquam vestibulum vel 165 | dolor id elementum. Ut bibendum nunc interdum neque interdum, vel tincidunt 166 | lacus blandit. Ut volutpat sollicitudin dapibus. Integer vitae lacinia ex, eget 167 | finibus nulla. Donec sit amet ante in neque pulvinar faucibus sed nec justo. 168 | Fusce hendrerit massa libero, sit amet pulvinar magna tempor quis. ø 169 | """) 170 | headers = inspect.cleandoc(""" 171 | ItemA: ValueA 172 | ItemB: ValueB 173 | ItemC: ValueC 174 | """) 175 | full = f"{headers}\n\n{body}" 176 | 177 | message.set_payload(textwrap.dedent(body)) 178 | 179 | assert str(message) == full 180 | 181 | new_message = email.message_from_string(str(message)) 182 | assert new_message.items() == message.items() 183 | assert new_message.get_payload() == message.get_payload() 184 | 185 | assert bytes(message) == full.encode("utf-8") 186 | 187 | 188 | def test_unknown_field() -> None: 189 | message = pyproject_metadata.RFC822Message() 190 | with pytest.raises( 191 | pyproject_metadata.ConfigurationError, 192 | match=re.escape("Unknown field 'Unknown'"), 193 | ): 194 | message["Unknown"] = "Value" 195 | 196 | 197 | def test_known_field() -> None: 198 | message = pyproject_metadata.RFC822Message() 199 | message["Platform"] = "Value" 200 | assert str(message) == "Platform: Value\n\n" 201 | 202 | 203 | def test_convert_optional_dependencies() -> None: 204 | metadata = pyproject_metadata.StandardMetadata.from_pyproject( 205 | { 206 | "project": { 207 | "name": "example", 208 | "version": "0.1.0", 209 | "optional-dependencies": { 210 | "test": [ 211 | 'foo; os_name == "nt" or sys_platform == "win32"', 212 | 'bar; os_name == "posix" and sys_platform == "linux"', 213 | ], 214 | }, 215 | }, 216 | } 217 | ) 218 | message = metadata.as_rfc822() 219 | requires = message.get_all("Requires-Dist") 220 | assert requires == [ 221 | 'foo; (os_name == "nt" or sys_platform == "win32") and extra == "test"', 222 | 'bar; os_name == "posix" and sys_platform == "linux" and extra == "test"', 223 | ] 224 | 225 | 226 | def test_convert_author_email() -> None: 227 | metadata = pyproject_metadata.StandardMetadata.from_pyproject( 228 | { 229 | "project": { 230 | "name": "example", 231 | "version": "0.1.0", 232 | "authors": [ 233 | { 234 | "name": "John Doe, Inc.", 235 | "email": "johndoe@example.com", 236 | }, 237 | { 238 | "name": "Kate Doe, LLC.", 239 | "email": "katedoe@example.com", 240 | }, 241 | ], 242 | }, 243 | } 244 | ) 245 | message = metadata.as_rfc822() 246 | assert message.get_all("Author-Email") == [ 247 | '"John Doe, Inc." , "Kate Doe, LLC." ' 248 | ] 249 | 250 | 251 | def test_long_version() -> None: 252 | metadata = pyproject_metadata.StandardMetadata.from_pyproject( 253 | { 254 | "project": { 255 | "name": "example", 256 | "version": "0.0.0+super.duper.long.version.string.that.is.longer.than.sixty.seven.characters", 257 | } 258 | } 259 | ) 260 | message = metadata.as_rfc822() 261 | assert ( 262 | message.get("Version") 263 | == "0.0.0+super.duper.long.version.string.that.is.longer.than.sixty.seven.characters" 264 | ) 265 | assert ( 266 | bytes(message) 267 | == inspect.cleandoc(""" 268 | Metadata-Version: 2.1 269 | Name: example 270 | Version: 0.0.0+super.duper.long.version.string.that.is.longer.than.sixty.seven.characters 271 | """).encode("utf-8") 272 | + b"\n\n" 273 | ) 274 | assert ( 275 | str(message) 276 | == inspect.cleandoc(""" 277 | Metadata-Version: 2.1 278 | Name: example 279 | Version: 0.0.0+super.duper.long.version.string.that.is.longer.than.sixty.seven.characters 280 | """) 281 | + "\n\n" 282 | ) 283 | -------------------------------------------------------------------------------- /tests/test_standard_metadata.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | from __future__ import annotations 4 | 5 | import contextlib 6 | import pathlib 7 | import re 8 | import shutil 9 | import sys 10 | import textwrap 11 | import warnings 12 | 13 | import packaging.specifiers 14 | import packaging.version 15 | import pytest 16 | 17 | if sys.version_info < (3, 11): 18 | import tomli as tomllib 19 | else: 20 | import tomllib 21 | 22 | import pyproject_metadata 23 | import pyproject_metadata.constants 24 | 25 | DIR = pathlib.Path(__file__).parent.resolve() 26 | 27 | 28 | try: 29 | import exceptiongroup 30 | except ImportError: 31 | exceptiongroup = None # type: ignore[assignment] 32 | 33 | 34 | @pytest.fixture(params=pyproject_metadata.constants.KNOWN_METADATA_VERSIONS) 35 | def metadata_version(request: pytest.FixtureRequest) -> str: 36 | return request.param # type: ignore[no-any-return] 37 | 38 | 39 | @pytest.fixture(params=["one_error", "all_errors", "exceptiongroup"]) 40 | def all_errors(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> bool: 41 | param: str = request.param 42 | if param == "exceptiongroup": 43 | if exceptiongroup is None: 44 | pytest.skip("exceptiongroup is not installed") 45 | monkeypatch.setattr( 46 | pyproject_metadata.errors, "ExceptionGroup", exceptiongroup.ExceptionGroup 47 | ) 48 | return param != "one_error" 49 | 50 | 51 | @pytest.mark.parametrize( 52 | ("data", "error"), 53 | [ 54 | pytest.param( 55 | "", 56 | 'Section "project" missing in pyproject.toml', 57 | id="Missing project section", 58 | ), 59 | pytest.param( 60 | """ 61 | [project] 62 | name = true 63 | version = "0.1.0" 64 | """, 65 | 'Field "project.name" has an invalid type, expecting a string (got bool)', 66 | id="Invalid name type", 67 | ), 68 | pytest.param( 69 | """ 70 | [project] 71 | name = "test" 72 | version = "0.1.0" 73 | not-real-key = true 74 | """, 75 | "Extra keys present in \"project\": 'not-real-key'", 76 | id="Invalid project key", 77 | ), 78 | pytest.param( 79 | """ 80 | [project] 81 | name = "test" 82 | version = "0.1.0" 83 | dynamic = [ 84 | "name", 85 | ] 86 | """, 87 | "Unsupported field 'name' in \"project.dynamic\"", 88 | id="Unsupported field in project.dynamic", 89 | ), 90 | pytest.param( 91 | """ 92 | [project] 93 | name = "test" 94 | version = "0.1.0" 95 | dynamic = [ 96 | 3, 97 | ] 98 | """, 99 | 'Field "project.dynamic" contains item with invalid type, expecting a string (got int)', 100 | id="Unsupported type in project.dynamic", 101 | ), 102 | pytest.param( 103 | """ 104 | [project] 105 | name = "test" 106 | version = true 107 | """, 108 | 'Field "project.version" has an invalid type, expecting a string (got bool)', 109 | id="Invalid version type", 110 | ), 111 | pytest.param( 112 | """ 113 | [project] 114 | name = "test" 115 | """, 116 | 'Field "project.version" missing and \'version\' not specified in "project.dynamic"', 117 | id="Missing version", 118 | ), 119 | pytest.param( 120 | """ 121 | [project] 122 | name = "test" 123 | version = "0.1.0-extra" 124 | """, 125 | "Invalid \"project.version\" value, expecting a valid PEP 440 version (got '0.1.0-extra')", 126 | id="Invalid version value", 127 | ), 128 | pytest.param( 129 | """ 130 | [project] 131 | name = "test" 132 | version = "0.1.0" 133 | license = true 134 | """, 135 | 'Field "project.license" has an invalid type, expecting a string or table of strings (got bool)', 136 | id="License invalid type", 137 | ), 138 | pytest.param( 139 | """ 140 | [project] 141 | name = "test" 142 | version = "0.1.0" 143 | license = {} 144 | """, 145 | 'Invalid "project.license" contents, expecting a string or one key "file" or "text" (got {})', 146 | id="Missing license keys", 147 | ), 148 | pytest.param( 149 | """ 150 | [project] 151 | name = "test" 152 | version = "0.1.0" 153 | license = { file = "...", text = "..." } 154 | """, 155 | ( 156 | 'Invalid "project.license" contents, expecting a string or one key "file" or "text"' 157 | " (got {'file': '...', 'text': '...'})" 158 | ), 159 | id="Both keys for license", 160 | ), 161 | pytest.param( 162 | """ 163 | [project] 164 | name = "test" 165 | version = "0.1.0" 166 | license = { made-up = ":(" } 167 | """, 168 | 'Unexpected field "project.license.made-up"', 169 | id="Got made-up license field", 170 | ), 171 | pytest.param( 172 | """ 173 | [project] 174 | name = "test" 175 | version = "0.1.0" 176 | license = { file = true } 177 | """, 178 | 'Field "project.license.file" has an invalid type, expecting a string (got bool)', 179 | id="Invalid type for license.file", 180 | ), 181 | pytest.param( 182 | """ 183 | [project] 184 | name = "test" 185 | version = "0.1.0" 186 | license = { text = true } 187 | """, 188 | 'Field "project.license.text" has an invalid type, expecting a string (got bool)', 189 | id="Invalid type for license.text", 190 | ), 191 | pytest.param( 192 | """ 193 | [project] 194 | name = "test" 195 | version = "0.1.0" 196 | license = { file = "this-file-does-not-exist" } 197 | """, 198 | "License file not found ('this-file-does-not-exist')", 199 | id="License file not present", 200 | ), 201 | pytest.param( 202 | """ 203 | [project] 204 | name = "test" 205 | version = "0.1.0" 206 | readme = true 207 | """, 208 | ( 209 | 'Field "project.readme" has an invalid type, expecting either ' 210 | "a string or table of strings (got bool)" 211 | ), 212 | id="Invalid readme type", 213 | ), 214 | pytest.param( 215 | """ 216 | [project] 217 | name = "test" 218 | version = "0.1.0" 219 | readme = {} 220 | """, 221 | 'Invalid "project.readme" contents, expecting either "file" or "text" (got {})', 222 | id="Empty readme table", 223 | ), 224 | pytest.param( 225 | """ 226 | [project] 227 | name = 'test' 228 | version = "0.1.0" 229 | readme = "README.jpg" 230 | """, 231 | "Could not infer content type for readme file 'README.jpg'", 232 | id="Unsupported filename in readme", 233 | ), 234 | pytest.param( 235 | """ 236 | [project] 237 | name = "test" 238 | version = "0.1.0" 239 | readme = { file = "...", text = "..." } 240 | """, 241 | ( 242 | 'Invalid "project.readme" contents, expecting either "file" or "text"' 243 | " (got {'file': '...', 'text': '...'})" 244 | ), 245 | id="Both readme fields", 246 | ), 247 | pytest.param( 248 | """ 249 | [project] 250 | name = "test" 251 | version = "0.1.0" 252 | readme = { made-up = ":(" } 253 | """, 254 | 'Unexpected field "project.readme.made-up"', 255 | id="Unexpected field in readme", 256 | ), 257 | pytest.param( 258 | """ 259 | [project] 260 | name = "test" 261 | version = "0.1.0" 262 | readme = { file = true } 263 | """, 264 | 'Field "project.readme.file" has an invalid type, expecting a string (got bool)', 265 | id="Invalid type for readme.file", 266 | ), 267 | pytest.param( 268 | """ 269 | [project] 270 | name = "test" 271 | version = "0.1.0" 272 | readme = { text = true } 273 | """, 274 | 'Field "project.readme.text" has an invalid type, expecting a string (got bool)', 275 | id="Invalid type for readme.text", 276 | ), 277 | pytest.param( 278 | """ 279 | [project] 280 | name = "test" 281 | version = "0.1.0" 282 | readme = { file = "this-file-does-not-exist", content-type = "..." } 283 | """, 284 | "Readme file not found ('this-file-does-not-exist')", 285 | id="Readme file not present", 286 | ), 287 | pytest.param( 288 | """ 289 | [project] 290 | name = "test" 291 | version = "0.1.0" 292 | readme = { file = "README.md" } 293 | """, 294 | 'Field "project.readme.content-type" missing', 295 | id="Missing content-type for readme", 296 | ), 297 | pytest.param( 298 | """ 299 | [project] 300 | name = "test" 301 | version = "0.1.0" 302 | readme = { file = 'README.md', content-type = true } 303 | """, 304 | 'Field "project.readme.content-type" has an invalid type, expecting a string (got bool)', 305 | id="Wrong content-type type for readme", 306 | ), 307 | pytest.param( 308 | """ 309 | [project] 310 | name = "test" 311 | version = "0.1.0" 312 | readme = { text = "..." } 313 | """, 314 | 'Field "project.readme.content-type" missing', 315 | id="Missing content-type for readme", 316 | ), 317 | pytest.param( 318 | """ 319 | [project] 320 | name = "test" 321 | version = "0.1.0" 322 | description = true 323 | """, 324 | 'Field "project.description" has an invalid type, expecting a string (got bool)', 325 | id="Invalid description type", 326 | ), 327 | pytest.param( 328 | """ 329 | [project] 330 | name = "test" 331 | version = "0.1.0" 332 | dependencies = "some string!" 333 | """, 334 | 'Field "project.dependencies" has an invalid type, expecting a list of strings (got str)', 335 | id="Invalid dependencies type", 336 | ), 337 | pytest.param( 338 | """ 339 | [project] 340 | name = "test" 341 | version = "0.1.0" 342 | dependencies = [ 343 | 99, 344 | ] 345 | """, 346 | 'Field "project.dependencies" contains item with invalid type, expecting a string (got int)', 347 | id="Invalid dependencies item type", 348 | ), 349 | pytest.param( 350 | """ 351 | [project] 352 | name = "test" 353 | version = "0.1.0" 354 | dependencies = [ 355 | "definitely not a valid PEP 508 requirement!", 356 | ] 357 | """, 358 | ( 359 | 'Field "project.dependencies" contains an invalid PEP 508 requirement ' 360 | "string 'definitely not a valid PEP 508 requirement!' " 361 | ), 362 | id="Invalid dependencies item", 363 | ), 364 | pytest.param( 365 | """ 366 | [project] 367 | name = "test" 368 | version = "0.1.0" 369 | optional-dependencies = true 370 | """, 371 | ( 372 | 'Field "project.optional-dependencies" has an invalid type, ' 373 | "expecting a table of PEP 508 requirement strings (got bool)" 374 | ), 375 | id="Invalid optional-dependencies type", 376 | ), 377 | pytest.param( 378 | """ 379 | [project] 380 | name = "test" 381 | version = "0.1.0" 382 | [project.optional-dependencies] 383 | test = "some string!" 384 | """, 385 | ( 386 | 'Field "project.optional-dependencies.test" has an invalid type, ' 387 | "expecting a table of PEP 508 requirement strings (got str)" 388 | ), 389 | id="Invalid optional-dependencies not list", 390 | ), 391 | pytest.param( 392 | """ 393 | [project] 394 | name = "test" 395 | version = "0.1.0" 396 | [project.optional-dependencies] 397 | test = [ 398 | true, 399 | ] 400 | """, 401 | ( 402 | 'Field "project.optional-dependencies.test" has an invalid type, ' 403 | "expecting a PEP 508 requirement string (got bool)" 404 | ), 405 | id="Invalid optional-dependencies item type", 406 | ), 407 | pytest.param( 408 | """ 409 | [project] 410 | name = "test" 411 | version = "0.1.0" 412 | [project.optional-dependencies] 413 | test = [ 414 | "definitely not a valid PEP 508 requirement!", 415 | ] 416 | """, 417 | ( 418 | 'Field "project.optional-dependencies.test" contains an invalid ' 419 | "PEP 508 requirement string 'definitely not a valid PEP 508 requirement!' " 420 | ), 421 | id="Invalid optional-dependencies item", 422 | ), 423 | pytest.param( 424 | """ 425 | [project] 426 | name = "test" 427 | version = "0.1.0" 428 | requires-python = true 429 | """, 430 | 'Field "project.requires-python" has an invalid type, expecting a string (got bool)', 431 | id="Invalid requires-python type", 432 | ), 433 | pytest.param( 434 | """ 435 | [project] 436 | name = "test" 437 | version = "0.1.0" 438 | requires-python = "3.8" 439 | """, 440 | "Invalid \"project.requires-python\" value, expecting a valid specifier set (got '3.8')", 441 | id="Invalid requires-python value", 442 | ), 443 | pytest.param( 444 | """ 445 | [project] 446 | name = "test" 447 | version = "0.1.0" 448 | keywords = "some string!" 449 | """, 450 | 'Field "project.keywords" has an invalid type, expecting a list of strings (got str)', 451 | id="Invalid keywords type", 452 | ), 453 | pytest.param( 454 | """ 455 | [project] 456 | name = "test" 457 | version = "0.1.0" 458 | keywords = [3] 459 | """, 460 | 'Field "project.keywords" contains item with invalid type, expecting a string (got int)', 461 | id="Invalid keyword type", 462 | ), 463 | pytest.param( 464 | """ 465 | [project] 466 | name = "test" 467 | version = "0.1.0" 468 | keywords = [ 469 | true, 470 | ] 471 | """, 472 | 'Field "project.keywords" contains item with invalid type, expecting a string (got bool)', 473 | id="Invalid keywords item type", 474 | ), 475 | pytest.param( 476 | """ 477 | [project] 478 | name = "test" 479 | version = "0.1.0" 480 | authors = {} 481 | """, 482 | ( 483 | 'Field "project.authors" has an invalid type, expecting a list of ' 484 | 'tables containing the "name" and/or "email" keys (got dict)' 485 | ), 486 | id="Invalid authors type", 487 | ), 488 | pytest.param( 489 | """ 490 | [project] 491 | name = "test" 492 | version = "0.1.0" 493 | authors = [ 494 | true, 495 | ] 496 | """, 497 | ( 498 | 'Field "project.authors" has an invalid type, expecting a list of ' 499 | 'tables containing the "name" and/or "email" keys (got list with bool)' 500 | ), 501 | id="Invalid authors item type", 502 | ), 503 | pytest.param( 504 | """ 505 | [project] 506 | name = "test" 507 | version = "0.1.0" 508 | maintainers = {} 509 | """, 510 | ( 511 | 'Field "project.maintainers" has an invalid type, expecting a list of ' 512 | 'tables containing the "name" and/or "email" keys (got dict)' 513 | ), 514 | id="Invalid maintainers type", 515 | ), 516 | pytest.param( 517 | """ 518 | [project] 519 | name = "test" 520 | version = "0.1.0" 521 | maintainers = [ 522 | 10 523 | ] 524 | """, 525 | ( 526 | 'Field "project.maintainers" has an invalid type, expecting a list of ' 527 | 'tables containing the "name" and/or "email" keys (got list with int)' 528 | ), 529 | id="Invalid maintainers item type", 530 | ), 531 | pytest.param( 532 | """ 533 | [project] 534 | name = "test" 535 | version = "0.1.0" 536 | maintainers = [ 537 | {"name" = 12} 538 | ] 539 | """, 540 | ( 541 | 'Field "project.maintainers" has an invalid type, expecting a list of ' 542 | 'tables containing the "name" and/or "email" keys (got list with dict with int)' 543 | ), 544 | id="Invalid maintainers nested type", 545 | ), 546 | pytest.param( 547 | """ 548 | [project] 549 | name = "test" 550 | version = "0.1.0" 551 | maintainers = [ 552 | {"name" = "me", "other" = "you"} 553 | ] 554 | """, 555 | ( 556 | 'Field "project.maintainers" has an invalid type, expecting a list of ' 557 | 'tables containing the "name" and/or "email" keys (got list with dict with extra keys "other")' 558 | ), 559 | id="Invalid maintainers nested type", 560 | ), 561 | pytest.param( 562 | """ 563 | [project] 564 | name = "test" 565 | version = "0.1.0" 566 | classifiers = "some string!" 567 | """, 568 | 'Field "project.classifiers" has an invalid type, expecting a list of strings (got str)', 569 | id="Invalid classifiers type", 570 | ), 571 | pytest.param( 572 | """ 573 | [project] 574 | name = "test" 575 | version = "0.1.0" 576 | classifiers = [ 577 | true, 578 | ] 579 | """, 580 | 'Field "project.classifiers" contains item with invalid type, expecting a string (got bool)', 581 | id="Invalid classifiers item type", 582 | ), 583 | pytest.param( 584 | """ 585 | [project] 586 | name = "test" 587 | version = "0.1.0" 588 | [project.urls] 589 | homepage = true 590 | """, 591 | 'Field "project.urls.homepage" has an invalid type, expecting a string (got bool)', 592 | id="Invalid urls homepage type", 593 | ), 594 | pytest.param( 595 | """ 596 | [project] 597 | name = "test" 598 | version = "0.1.0" 599 | [project.urls] 600 | Documentation = true 601 | """, 602 | 'Field "project.urls.Documentation" has an invalid type, expecting a string (got bool)', 603 | id="Invalid urls documentation type", 604 | ), 605 | pytest.param( 606 | """ 607 | [project] 608 | name = "test" 609 | version = "0.1.0" 610 | [project.urls] 611 | repository = true 612 | """, 613 | 'Field "project.urls.repository" has an invalid type, expecting a string (got bool)', 614 | id="Invalid urls repository type", 615 | ), 616 | pytest.param( 617 | """ 618 | [project] 619 | name = "test" 620 | version = "0.1.0" 621 | [project.urls] 622 | "I am really really too long for this place" = "url" 623 | """, 624 | "\"project.urls\" names cannot be more than 32 characters long (got 'I am really really too long for this place')", 625 | id="URL name too long", 626 | ), 627 | pytest.param( 628 | """ 629 | [project] 630 | name = "test" 631 | version = "0.1.0" 632 | [project.urls] 633 | changelog = true 634 | """, 635 | 'Field "project.urls.changelog" has an invalid type, expecting a string (got bool)', 636 | id="Invalid urls changelog type", 637 | ), 638 | pytest.param( 639 | """ 640 | [project] 641 | name = "test" 642 | version = "0.1.0" 643 | scripts = [] 644 | """, 645 | 'Field "project.scripts" has an invalid type, expecting a table of strings (got list)', 646 | id="Invalid scripts type", 647 | ), 648 | pytest.param( 649 | """ 650 | [project] 651 | name = "test" 652 | version = "0.1.0" 653 | gui-scripts = [] 654 | """, 655 | 'Field "project.gui-scripts" has an invalid type, expecting a table of strings (got list)', 656 | id="Invalid gui-scripts type", 657 | ), 658 | pytest.param( 659 | """ 660 | [project] 661 | name = "test" 662 | version = "0.1.0" 663 | entry-points = [] 664 | """, 665 | ( 666 | 'Field "project.entry-points" has an invalid type, ' 667 | "expecting a table of entrypoint sections (got list)" 668 | ), 669 | id="Invalid entry-points type", 670 | ), 671 | pytest.param( 672 | """ 673 | [project] 674 | name = "test" 675 | version = "0.1.0" 676 | entry-points = { section = "something" } 677 | """, 678 | ( 679 | 'Field "project.entry-points.section" has an invalid type, ' 680 | "expecting a table of entrypoints (got str)" 681 | ), 682 | id="Invalid entry-points section type", 683 | ), 684 | pytest.param( 685 | """ 686 | [project] 687 | name = "test" 688 | version = "0.1.0" 689 | [project.entry-points.section] 690 | entrypoint = [] 691 | """, 692 | 'Field "project.entry-points.section.entrypoint" has an invalid type, expecting a string (got list)', 693 | id="Invalid entry-points entrypoint type", 694 | ), 695 | pytest.param( 696 | """ 697 | [project] 698 | name = ".test" 699 | version = "0.1.0" 700 | """, 701 | ( 702 | "Invalid project name '.test'. A valid name consists only of ASCII letters and " 703 | "numbers, period, underscore and hyphen. It must start and end with a letter or number" 704 | ), 705 | id="Invalid project name", 706 | ), 707 | pytest.param( 708 | """ 709 | [project] 710 | name = "test" 711 | version = "0.1.0" 712 | [project.entry-points.bad-name] 713 | """, 714 | ( 715 | 'Field "project.entry-points" has an invalid value, expecting a name containing only ' 716 | "alphanumeric, underscore, or dot characters (got 'bad-name')" 717 | ), 718 | id="Invalid entry-points name", 719 | ), 720 | # both license files and classic license are not allowed 721 | pytest.param( 722 | """ 723 | [project] 724 | name = "test" 725 | version = "0.1.0" 726 | license-files = [] 727 | license.text = 'stuff' 728 | """, 729 | '"project.license-files" must not be used when "project.license" is not a SPDX license expression', 730 | id="Both license files and classic license", 731 | ), 732 | pytest.param( 733 | """ 734 | [project] 735 | name = "test" 736 | version = "0.1.0" 737 | license-files = ['../LICENSE'] 738 | """, 739 | "'../LICENSE' is an invalid \"project.license-files\" glob: the pattern must match files within the project directory", 740 | id="Parent license-files glob", 741 | ), 742 | pytest.param( 743 | """ 744 | [project] 745 | name = "test" 746 | version = "0.1.0" 747 | license-files = [12] 748 | """, 749 | 'Field "project.license-files" contains item with invalid type, expecting a string (got int)', 750 | id="Parent license-files invalid type", 751 | ), 752 | pytest.param( 753 | """ 754 | [project] 755 | name = "test" 756 | version = "0.1.0" 757 | license-files = ['this', 12] 758 | """, 759 | 'Field "project.license-files" contains item with invalid type, expecting a string (got int)', 760 | id="Parent license-files invalid type", 761 | ), 762 | pytest.param( 763 | """ 764 | [project] 765 | name = "test" 766 | version = "0.1.0" 767 | license-files = ['/LICENSE'] 768 | """, 769 | "'/LICENSE' is an invalid \"project.license-files\" glob: the pattern must match files within the project directory", 770 | id="Absolute license-files glob", 771 | ), 772 | pytest.param( 773 | """ 774 | [project] 775 | name = "test" 776 | version = "0.1.0" 777 | license = 'MIT' 778 | classifiers = ['License :: OSI Approved :: MIT License'] 779 | """, 780 | "Setting \"project.license\" to an SPDX license expression is not compatible with 'License ::' classifiers", 781 | id="SPDX license and License trove classifiers", 782 | ), 783 | ], 784 | ) 785 | def test_load( 786 | data: str, error: str, monkeypatch: pytest.MonkeyPatch, all_errors: bool 787 | ) -> None: 788 | monkeypatch.chdir(DIR / "packages/full-metadata") 789 | if not all_errors: 790 | with pytest.raises( 791 | pyproject_metadata.ConfigurationError, match=re.escape(error) 792 | ): 793 | pyproject_metadata.StandardMetadata.from_pyproject( 794 | tomllib.loads(textwrap.dedent(data)), 795 | allow_extra_keys=False, 796 | ) 797 | else: 798 | with warnings.catch_warnings(): 799 | warnings.simplefilter( 800 | action="ignore", category=pyproject_metadata.errors.ConfigurationWarning 801 | ) 802 | with pytest.raises(pyproject_metadata.errors.ExceptionGroup) as execinfo: 803 | pyproject_metadata.StandardMetadata.from_pyproject( 804 | tomllib.loads(textwrap.dedent(data)), 805 | allow_extra_keys=False, 806 | all_errors=True, 807 | ) 808 | exceptions = execinfo.value.exceptions 809 | args = [e.args[0] for e in exceptions] 810 | assert len(args) == 1 811 | assert error in args[0] 812 | assert "Failed to parse pyproject.toml" in repr(execinfo.value) 813 | 814 | 815 | @pytest.mark.parametrize( 816 | ("data", "errors"), 817 | [ 818 | pytest.param( 819 | "[project]", 820 | [ 821 | 'Field "project.name" missing', 822 | 'Field "project.version" missing and \'version\' not specified in "project.dynamic"', 823 | ], 824 | id="Missing project name", 825 | ), 826 | pytest.param( 827 | """ 828 | [project] 829 | name = true 830 | version = "0.1.0" 831 | dynamic = [ 832 | "name", 833 | ] 834 | """, 835 | [ 836 | "Unsupported field 'name' in \"project.dynamic\"", 837 | 'Field "project.name" has an invalid type, expecting a string (got bool)', 838 | ], 839 | id="Unsupported field in project.dynamic", 840 | ), 841 | pytest.param( 842 | """ 843 | [project] 844 | name = true 845 | version = "0.1.0" 846 | dynamic = [ 847 | 3, 848 | ] 849 | """, 850 | [ 851 | 'Field "project.dynamic" contains item with invalid type, expecting a string (got int)', 852 | 'Field "project.name" has an invalid type, expecting a string (got bool)', 853 | ], 854 | id="Unsupported type in project.dynamic", 855 | ), 856 | pytest.param( 857 | """ 858 | [project] 859 | name = 'test' 860 | version = "0.1.0" 861 | readme = "README.jpg" 862 | license-files = [12] 863 | """, 864 | [ 865 | 'Field "project.license-files" contains item with invalid type, expecting a string (got int)', 866 | "Could not infer content type for readme file 'README.jpg'", 867 | ], 868 | id="Unsupported filename in readme", 869 | ), 870 | pytest.param( 871 | """ 872 | [project] 873 | name = 'test' 874 | version = "0.1.0" 875 | readme = "README.jpg" 876 | license-files = [12] 877 | entry-points.bad-name = {} 878 | other-entry = {} 879 | not-valid = true 880 | """, 881 | [ 882 | "Extra keys present in \"project\": 'not-valid', 'other-entry'", 883 | 'Field "project.license-files" contains item with invalid type, expecting a string (got int)', 884 | "Could not infer content type for readme file 'README.jpg'", 885 | "Field \"project.entry-points\" has an invalid value, expecting a name containing only alphanumeric, underscore, or dot characters (got 'bad-name')", 886 | ], 887 | id="Four errors including extra keys", 888 | ), 889 | ], 890 | ) 891 | def test_load_multierror( 892 | data: str, errors: list[str], monkeypatch: pytest.MonkeyPatch, all_errors: bool 893 | ) -> None: 894 | monkeypatch.chdir(DIR / "packages/full-metadata") 895 | if not all_errors: 896 | with pytest.raises( 897 | pyproject_metadata.ConfigurationError, match=re.escape(errors[0]) 898 | ): 899 | pyproject_metadata.StandardMetadata.from_pyproject( 900 | tomllib.loads(textwrap.dedent(data)), 901 | allow_extra_keys=False, 902 | ) 903 | else: 904 | with warnings.catch_warnings(): 905 | warnings.simplefilter( 906 | action="ignore", category=pyproject_metadata.errors.ConfigurationWarning 907 | ) 908 | with pytest.raises(pyproject_metadata.errors.ExceptionGroup) as execinfo: 909 | pyproject_metadata.StandardMetadata.from_pyproject( 910 | tomllib.loads(textwrap.dedent(data)), 911 | allow_extra_keys=False, 912 | all_errors=True, 913 | ) 914 | exceptions = execinfo.value.exceptions 915 | args = [e.args[0] for e in exceptions] 916 | assert len(args) == len(errors) 917 | assert args == errors 918 | assert "Failed to parse pyproject.toml" in repr(execinfo.value) 919 | 920 | 921 | @pytest.mark.parametrize( 922 | ("data", "error", "metadata_version"), 923 | [ 924 | pytest.param( 925 | """ 926 | [project] 927 | name = "test" 928 | version = "0.1.0" 929 | license = 'MIT' 930 | """, 931 | 'Setting "project.license" to an SPDX license expression is supported only when emitting metadata version >= 2.4', 932 | "2.3", 933 | id="SPDX with metadata_version 2.3", 934 | ), 935 | pytest.param( 936 | """ 937 | [project] 938 | name = "test" 939 | version = "0.1.0" 940 | license-files = ['README.md'] 941 | """, 942 | '"project.license-files" is supported only when emitting metadata version >= 2.4', 943 | "2.3", 944 | id="license-files with metadata_version 2.3", 945 | ), 946 | ], 947 | ) 948 | def test_load_with_metadata_version( 949 | data: str, error: str, metadata_version: str, monkeypatch: pytest.MonkeyPatch 950 | ) -> None: 951 | monkeypatch.chdir(DIR / "packages/full-metadata") 952 | with pytest.raises(pyproject_metadata.ConfigurationError, match=re.escape(error)): 953 | pyproject_metadata.StandardMetadata.from_pyproject( 954 | tomllib.loads(textwrap.dedent(data)), metadata_version=metadata_version 955 | ) 956 | 957 | 958 | @pytest.mark.parametrize( 959 | ("data", "error", "metadata_version"), 960 | [ 961 | pytest.param( 962 | """ 963 | [project] 964 | name = "test" 965 | version = "0.1.0" 966 | license.text = 'MIT' 967 | """, 968 | 'Set "project.license" to an SPDX license expression for metadata >= 2.4', 969 | "2.4", 970 | id="Classic license with metadata 2.4", 971 | ), 972 | pytest.param( 973 | """ 974 | [project] 975 | name = "test" 976 | version = "0.1.0" 977 | classifiers = ['License :: OSI Approved :: MIT License'] 978 | """, 979 | "'License ::' classifiers are deprecated for metadata >= 2.4, use a SPDX license expression for \"project.license\" instead", 980 | "2.4", 981 | id="License trove classifiers with metadata 2.4", 982 | ), 983 | ], 984 | ) 985 | def test_load_with_metadata_version_warnings( 986 | data: str, error: str, metadata_version: str, monkeypatch: pytest.MonkeyPatch 987 | ) -> None: 988 | monkeypatch.chdir(DIR / "packages/full-metadata") 989 | with pytest.warns( 990 | pyproject_metadata.errors.ConfigurationWarning, match=re.escape(error) 991 | ): 992 | pyproject_metadata.StandardMetadata.from_pyproject( 993 | tomllib.loads(textwrap.dedent(data)), metadata_version=metadata_version 994 | ) 995 | 996 | 997 | @pytest.mark.parametrize("after_rfc", [False, True]) 998 | def test_value(after_rfc: bool, monkeypatch: pytest.MonkeyPatch) -> None: 999 | monkeypatch.chdir(DIR / "packages/full-metadata") 1000 | with open("pyproject.toml", "rb") as f: 1001 | metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f)) 1002 | 1003 | if after_rfc: 1004 | metadata.as_rfc822() 1005 | 1006 | assert metadata.dynamic == [] 1007 | assert metadata.name == "full_metadata" 1008 | assert metadata.canonical_name == "full-metadata" 1009 | assert metadata.version == packaging.version.Version("3.2.1") 1010 | assert metadata.requires_python == packaging.specifiers.Specifier(">=3.8") 1011 | assert isinstance(metadata.license, pyproject_metadata.License) 1012 | assert metadata.license.file is None 1013 | assert metadata.license.text == "some license text" 1014 | assert isinstance(metadata.readme, pyproject_metadata.Readme) 1015 | assert metadata.readme.file == pathlib.Path("README.md") 1016 | assert metadata.readme.text == pathlib.Path("README.md").read_text(encoding="utf-8") 1017 | assert metadata.readme.content_type == "text/markdown" 1018 | assert metadata.description == "A package with all the metadata :)" 1019 | assert metadata.authors == [ 1020 | ("Unknown", "example@example.com"), 1021 | ("Example!", None), 1022 | ] 1023 | assert metadata.maintainers == [ 1024 | ("Other Example", "other@example.com"), 1025 | ] 1026 | assert metadata.keywords == ["trampolim", "is", "interesting"] 1027 | assert metadata.classifiers == [ 1028 | "Development Status :: 4 - Beta", 1029 | "Programming Language :: Python", 1030 | ] 1031 | assert metadata.urls == { 1032 | "changelog": "github.com/some/repo/blob/master/CHANGELOG.rst", 1033 | "documentation": "readthedocs.org", 1034 | "homepage": "example.com", 1035 | "repository": "github.com/some/repo", 1036 | } 1037 | assert metadata.entrypoints == { 1038 | "custom": { 1039 | "full-metadata": "full_metadata:main_custom", 1040 | }, 1041 | } 1042 | assert metadata.scripts == { 1043 | "full-metadata": "full_metadata:main_cli", 1044 | } 1045 | assert metadata.gui_scripts == { 1046 | "full-metadata-gui": "full_metadata:main_gui", 1047 | } 1048 | assert list(map(str, metadata.dependencies)) == [ 1049 | "dependency1", 1050 | "dependency2>1.0.0", 1051 | "dependency3[extra]", 1052 | 'dependency4; os_name != "nt"', 1053 | 'dependency5[other-extra]>1.0; os_name == "nt"', 1054 | ] 1055 | assert list(metadata.optional_dependencies.keys()) == ["test"] 1056 | assert list(map(str, metadata.optional_dependencies["test"])) == [ 1057 | "test_dependency", 1058 | "test_dependency[test_extra]", 1059 | 'test_dependency[test_extra2]>3.0; os_name == "nt"', 1060 | ] 1061 | 1062 | 1063 | def test_read_license(monkeypatch: pytest.MonkeyPatch) -> None: 1064 | monkeypatch.chdir(DIR / "packages/full-metadata2") 1065 | with open("pyproject.toml", "rb") as f: 1066 | metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f)) 1067 | 1068 | assert isinstance(metadata.license, pyproject_metadata.License) 1069 | assert metadata.license.file == pathlib.Path("LICENSE") 1070 | assert metadata.license.text == "Some license! 👋\n" 1071 | 1072 | 1073 | @pytest.mark.parametrize( 1074 | ("package", "content_type"), 1075 | [ 1076 | ("full-metadata", "text/markdown"), 1077 | ("full-metadata2", "text/x-rst"), 1078 | ], 1079 | ) 1080 | def test_readme_content_type( 1081 | package: str, content_type: str, monkeypatch: pytest.MonkeyPatch 1082 | ) -> None: 1083 | monkeypatch.chdir(DIR / "packages" / package) 1084 | with open("pyproject.toml", "rb") as f: 1085 | metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f)) 1086 | 1087 | assert isinstance(metadata.readme, pyproject_metadata.Readme) 1088 | assert metadata.readme.content_type == content_type 1089 | 1090 | 1091 | def test_readme_content_type_unknown(monkeypatch: pytest.MonkeyPatch) -> None: 1092 | monkeypatch.chdir(DIR / "packages/unknown-readme-type") 1093 | with pytest.raises( 1094 | pyproject_metadata.ConfigurationError, 1095 | match=re.escape( 1096 | "Could not infer content type for readme file 'README.just-made-this-up-now'" 1097 | ), 1098 | ), open("pyproject.toml", "rb") as f: 1099 | pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f)) 1100 | 1101 | 1102 | def test_as_json(monkeypatch: pytest.MonkeyPatch) -> None: 1103 | monkeypatch.chdir(DIR / "packages/full-metadata") 1104 | 1105 | with open("pyproject.toml", "rb") as f: 1106 | metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f)) 1107 | core_metadata = metadata.as_json() 1108 | 1109 | assert core_metadata == { 1110 | "author": "Example!", 1111 | "author_email": "Unknown ", 1112 | "classifier": [ 1113 | "Development Status :: 4 - Beta", 1114 | "Programming Language :: Python", 1115 | ], 1116 | "description": "some readme 👋\n", 1117 | "description_content_type": "text/markdown", 1118 | "keywords": ["trampolim", "is", "interesting"], 1119 | "license": "some license text", 1120 | "maintainer_email": "Other Example ", 1121 | "metadata_version": "2.1", 1122 | "name": "full_metadata", 1123 | "project_url": [ 1124 | "homepage, example.com", 1125 | "documentation, readthedocs.org", 1126 | "repository, github.com/some/repo", 1127 | "changelog, github.com/some/repo/blob/master/CHANGELOG.rst", 1128 | ], 1129 | "provides_extra": ["test"], 1130 | "requires_dist": [ 1131 | "dependency1", 1132 | "dependency2>1.0.0", 1133 | "dependency3[extra]", 1134 | 'dependency4; os_name != "nt"', 1135 | 'dependency5[other-extra]>1.0; os_name == "nt"', 1136 | 'test_dependency; extra == "test"', 1137 | 'test_dependency[test_extra]; extra == "test"', 1138 | 'test_dependency[test_extra2]>3.0; os_name == "nt" and extra == "test"', 1139 | ], 1140 | "requires_python": ">=3.8", 1141 | "summary": "A package with all the metadata :)", 1142 | "version": "3.2.1", 1143 | } 1144 | 1145 | 1146 | def test_as_rfc822(monkeypatch: pytest.MonkeyPatch) -> None: 1147 | monkeypatch.chdir(DIR / "packages/full-metadata") 1148 | 1149 | with open("pyproject.toml", "rb") as f: 1150 | metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f)) 1151 | core_metadata = metadata.as_rfc822() 1152 | assert core_metadata.items() == [ 1153 | ("Metadata-Version", "2.1"), 1154 | ("Name", "full_metadata"), 1155 | ("Version", "3.2.1"), 1156 | ("Summary", "A package with all the metadata :)"), 1157 | ("Keywords", "trampolim,is,interesting"), 1158 | ("Author", "Example!"), 1159 | ("Author-Email", "Unknown "), 1160 | ("Maintainer-Email", "Other Example "), 1161 | ("License", "some license text"), 1162 | ("Classifier", "Development Status :: 4 - Beta"), 1163 | ("Classifier", "Programming Language :: Python"), 1164 | ("Project-URL", "homepage, example.com"), 1165 | ("Project-URL", "documentation, readthedocs.org"), 1166 | ("Project-URL", "repository, github.com/some/repo"), 1167 | ("Project-URL", "changelog, github.com/some/repo/blob/master/CHANGELOG.rst"), 1168 | ("Requires-Python", ">=3.8"), 1169 | ("Requires-Dist", "dependency1"), 1170 | ("Requires-Dist", "dependency2>1.0.0"), 1171 | ("Requires-Dist", "dependency3[extra]"), 1172 | ("Requires-Dist", 'dependency4; os_name != "nt"'), 1173 | ("Requires-Dist", 'dependency5[other-extra]>1.0; os_name == "nt"'), 1174 | ("Provides-Extra", "test"), 1175 | ("Requires-Dist", 'test_dependency; extra == "test"'), 1176 | ("Requires-Dist", 'test_dependency[test_extra]; extra == "test"'), 1177 | ( 1178 | "Requires-Dist", 1179 | 'test_dependency[test_extra2]>3.0; os_name == "nt" and extra == "test"', 1180 | ), 1181 | ("Description-Content-Type", "text/markdown"), 1182 | ] 1183 | assert core_metadata.get_payload() == "some readme 👋\n" 1184 | 1185 | 1186 | def test_as_json_spdx(monkeypatch: pytest.MonkeyPatch) -> None: 1187 | monkeypatch.chdir(DIR / "packages/spdx") 1188 | 1189 | with open("pyproject.toml", "rb") as f: 1190 | metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f)) 1191 | core_metadata = metadata.as_json() 1192 | assert core_metadata == { 1193 | "license_expression": "MIT OR GPL-2.0-or-later OR (FSFUL AND BSD-2-Clause)", 1194 | "license_file": [ 1195 | "AUTHORS.txt", 1196 | "LICENSE.md", 1197 | "LICENSE.txt", 1198 | "licenses/LICENSE.MIT", 1199 | ], 1200 | "metadata_version": "2.4", 1201 | "name": "example", 1202 | "version": "1.2.3", 1203 | } 1204 | 1205 | 1206 | def test_as_rfc822_spdx(monkeypatch: pytest.MonkeyPatch) -> None: 1207 | monkeypatch.chdir(DIR / "packages/spdx") 1208 | 1209 | with open("pyproject.toml", "rb") as f: 1210 | metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f)) 1211 | core_metadata = metadata.as_rfc822() 1212 | assert core_metadata.items() == [ 1213 | ("Metadata-Version", "2.4"), 1214 | ("Name", "example"), 1215 | ("Version", "1.2.3"), 1216 | ("License-Expression", "MIT OR GPL-2.0-or-later OR (FSFUL AND BSD-2-Clause)"), 1217 | ("License-File", "AUTHORS.txt"), 1218 | ("License-File", "LICENSE.md"), 1219 | ("License-File", "LICENSE.txt"), 1220 | ("License-File", "licenses/LICENSE.MIT"), 1221 | ] 1222 | 1223 | assert core_metadata.get_payload() is None 1224 | 1225 | 1226 | def test_as_rfc822_spdx_empty_glob( 1227 | monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, all_errors: bool 1228 | ) -> None: 1229 | shutil.copytree(DIR / "packages/spdx", tmp_path / "spdx") 1230 | monkeypatch.chdir(tmp_path / "spdx") 1231 | 1232 | pathlib.Path("AUTHORS.txt").unlink() 1233 | msg = "Every pattern in \"project.license-files\" must match at least one file: 'AUTHORS*' did not match any" 1234 | 1235 | with open("pyproject.toml", "rb") as f: 1236 | if all_errors: 1237 | with pytest.raises( 1238 | pyproject_metadata.errors.ExceptionGroup, 1239 | ) as execinfo: 1240 | pyproject_metadata.StandardMetadata.from_pyproject( 1241 | tomllib.load(f), all_errors=all_errors 1242 | ) 1243 | assert "Failed to parse pyproject.toml" in str(execinfo.value) 1244 | assert [msg] == [str(e) for e in execinfo.value.exceptions] 1245 | else: 1246 | with pytest.raises( 1247 | pyproject_metadata.ConfigurationError, 1248 | match=re.escape(msg), 1249 | ): 1250 | pyproject_metadata.StandardMetadata.from_pyproject( 1251 | tomllib.load(f), all_errors=all_errors 1252 | ) 1253 | 1254 | 1255 | def test_license_file_24( 1256 | metadata_version: str, monkeypatch: pytest.MonkeyPatch 1257 | ) -> None: 1258 | monkeypatch.chdir(DIR / "packages/fulltext_license") 1259 | pre_spdx = ( 1260 | metadata_version in pyproject_metadata.constants.PRE_SPDX_METADATA_VERSIONS 1261 | ) 1262 | with ( 1263 | contextlib.nullcontext() 1264 | if pre_spdx 1265 | else pytest.warns(pyproject_metadata.errors.ConfigurationWarning) 1266 | ): 1267 | metadata = pyproject_metadata.StandardMetadata.from_pyproject( 1268 | { 1269 | "project": { 1270 | "name": "fulltext_license", 1271 | "version": "0.1.0", 1272 | "license": {"file": "LICENSE.txt"}, 1273 | }, 1274 | }, 1275 | metadata_version=metadata_version, 1276 | ) 1277 | message = str(metadata.as_rfc822()) 1278 | if metadata_version in pyproject_metadata.constants.PRE_SPDX_METADATA_VERSIONS: 1279 | assert "License-File: LICENSE.txt" not in message 1280 | else: 1281 | assert "License-File: LICENSE.txt" in message 1282 | 1283 | bmessage = bytes(metadata.as_rfc822()) 1284 | if metadata_version in pyproject_metadata.constants.PRE_SPDX_METADATA_VERSIONS: 1285 | assert b"License-File: LICENSE.txt" not in bmessage 1286 | else: 1287 | assert b"License-File: LICENSE.txt" in bmessage 1288 | 1289 | 1290 | def test_license_file_broken(monkeypatch: pytest.MonkeyPatch) -> None: 1291 | monkeypatch.chdir(DIR / "packages/broken_license") 1292 | 1293 | metadata = pyproject_metadata.StandardMetadata.from_pyproject( 1294 | { 1295 | "project": { 1296 | "name": "broken_license", 1297 | "version": "0.1.0", 1298 | "license": {"file": "LICENSE"}, 1299 | }, 1300 | }, 1301 | ) 1302 | message = str(metadata.as_rfc822()) 1303 | assert "License-File: LICENSE" not in message 1304 | bmessage = bytes(metadata.as_rfc822()) 1305 | assert b"License-File: LICENSE" not in bmessage 1306 | 1307 | 1308 | def test_as_rfc822_dynamic(monkeypatch: pytest.MonkeyPatch) -> None: 1309 | monkeypatch.chdir(DIR / "packages/dynamic-description") 1310 | 1311 | with open("pyproject.toml", "rb") as f: 1312 | metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f)) 1313 | metadata.dynamic_metadata = ["description"] 1314 | core_metadata = metadata.as_rfc822() 1315 | assert core_metadata.items() == [ 1316 | ("Metadata-Version", "2.2"), 1317 | ("Name", "dynamic-description"), 1318 | ("Version", "1.0.0"), 1319 | ("Dynamic", "description"), 1320 | ] 1321 | 1322 | 1323 | def test_as_rfc822_set_metadata(metadata_version: str) -> None: 1324 | metadata = pyproject_metadata.StandardMetadata.from_pyproject( 1325 | { 1326 | "project": { 1327 | "name": "hi", 1328 | "version": "1.2", 1329 | "optional-dependencies": { 1330 | "under_score": ["some_package"], 1331 | "da-sh": ["some-package"], 1332 | "do.t": ["some.package"], 1333 | "empty": [], 1334 | }, 1335 | } 1336 | }, 1337 | metadata_version=metadata_version, 1338 | ) 1339 | assert metadata.metadata_version == metadata_version 1340 | 1341 | rfc822 = bytes(metadata.as_rfc822()).decode("utf-8") 1342 | 1343 | assert f"Metadata-Version: {metadata_version}" in rfc822 1344 | 1345 | assert "Provides-Extra: under-score" in rfc822 1346 | assert "Provides-Extra: da-sh" in rfc822 1347 | assert "Provides-Extra: do-t" in rfc822 1348 | assert "Provides-Extra: empty" in rfc822 1349 | assert 'Requires-Dist: some_package; extra == "under-score"' in rfc822 1350 | assert 'Requires-Dist: some-package; extra == "da-sh"' in rfc822 1351 | assert 'Requires-Dist: some.package; extra == "do-t"' in rfc822 1352 | 1353 | 1354 | def test_as_json_set_metadata() -> None: 1355 | metadata = pyproject_metadata.StandardMetadata.from_pyproject( 1356 | { 1357 | "project": { 1358 | "name": "hi", 1359 | "version": "1.2", 1360 | "optional-dependencies": { 1361 | "under_score": ["some_package"], 1362 | "da-sh": ["some-package"], 1363 | "do.t": ["some.package"], 1364 | "empty": [], 1365 | }, 1366 | } 1367 | }, 1368 | metadata_version="2.1", 1369 | ) 1370 | assert metadata.metadata_version == "2.1" 1371 | 1372 | json = metadata.as_json() 1373 | 1374 | assert json == { 1375 | "metadata_version": "2.1", 1376 | "name": "hi", 1377 | "provides_extra": ["under-score", "da-sh", "do-t", "empty"], 1378 | "requires_dist": [ 1379 | 'some_package; extra == "under-score"', 1380 | 'some-package; extra == "da-sh"', 1381 | 'some.package; extra == "do-t"', 1382 | ], 1383 | "version": "1.2", 1384 | } 1385 | 1386 | 1387 | def test_as_rfc822_set_metadata_invalid() -> None: 1388 | with pytest.raises( 1389 | pyproject_metadata.ConfigurationError, 1390 | match="The metadata_version must be one of", 1391 | ) as err: 1392 | pyproject_metadata.StandardMetadata.from_pyproject( 1393 | { 1394 | "project": { 1395 | "name": "hi", 1396 | "version": "1.2", 1397 | }, 1398 | }, 1399 | metadata_version="2.0", 1400 | ) 1401 | assert "2.1" in str(err.value) 1402 | assert "2.2" in str(err.value) 1403 | assert "2.3" in str(err.value) 1404 | 1405 | 1406 | def test_as_rfc822_invalid_dynamic() -> None: 1407 | metadata = pyproject_metadata.StandardMetadata( 1408 | name="something", 1409 | version=packaging.version.Version("1.0.0"), 1410 | dynamic_metadata=["name"], 1411 | ) 1412 | with pytest.raises( 1413 | pyproject_metadata.ConfigurationError, 1414 | match="Metadata field 'name' cannot be declared dynamic", 1415 | ): 1416 | metadata.as_rfc822() 1417 | metadata.dynamic_metadata = ["Version"] 1418 | with pytest.raises( 1419 | pyproject_metadata.ConfigurationError, 1420 | match="Metadata field 'Version' cannot be declared dynamic", 1421 | ): 1422 | metadata.as_rfc822() 1423 | metadata.dynamic_metadata = ["unknown"] 1424 | with pytest.raises( 1425 | pyproject_metadata.ConfigurationError, 1426 | match="Unknown metadata field 'unknown' cannot be declared dynamic", 1427 | ): 1428 | metadata.as_rfc822() 1429 | 1430 | 1431 | def test_as_rfc822_mapped_dynamic() -> None: 1432 | metadata = pyproject_metadata.StandardMetadata( 1433 | name="something", 1434 | version=packaging.version.Version("1.0.0"), 1435 | dynamic_metadata=list(pyproject_metadata.field_to_metadata("description")), 1436 | ) 1437 | assert ( 1438 | str(metadata.as_rfc822()) 1439 | == "Metadata-Version: 2.2\nName: something\nVersion: 1.0.0\nDynamic: Summary\n\n" 1440 | ) 1441 | 1442 | 1443 | def test_as_rfc822_missing_version() -> None: 1444 | metadata = pyproject_metadata.StandardMetadata(name="something") 1445 | with pytest.raises( 1446 | pyproject_metadata.ConfigurationError, match='Field "project.version" missing' 1447 | ): 1448 | metadata.as_rfc822() 1449 | 1450 | 1451 | def test_statically_defined_dynamic_field() -> None: 1452 | with pytest.raises( 1453 | pyproject_metadata.ConfigurationError, 1454 | match='Field "project.version" declared as dynamic in "project.dynamic" but is defined', 1455 | ): 1456 | pyproject_metadata.StandardMetadata.from_pyproject( 1457 | { 1458 | "project": { 1459 | "name": "example", 1460 | "version": "1.2.3", 1461 | "dynamic": [ 1462 | "version", 1463 | ], 1464 | }, 1465 | } 1466 | ) 1467 | 1468 | 1469 | @pytest.mark.parametrize( 1470 | "value", 1471 | [ 1472 | "<3.10", 1473 | ">3.8,<3.11", 1474 | ">3.8,<3.11,!=3.8.4", 1475 | "~=3.10,!=3.10.3", 1476 | ], 1477 | ) 1478 | def test_requires_python(value: str) -> None: 1479 | pyproject_metadata.StandardMetadata.from_pyproject( 1480 | { 1481 | "project": { 1482 | "name": "example", 1483 | "version": "0.1.0", 1484 | "requires-python": value, 1485 | }, 1486 | } 1487 | ) 1488 | 1489 | 1490 | def test_version_dynamic() -> None: 1491 | metadata = pyproject_metadata.StandardMetadata.from_pyproject( 1492 | { 1493 | "project": { 1494 | "name": "example", 1495 | "dynamic": [ 1496 | "version", 1497 | ], 1498 | }, 1499 | } 1500 | ) 1501 | metadata.version = packaging.version.Version("1.2.3") 1502 | 1503 | 1504 | def test_modify_dynamic() -> None: 1505 | metadata = pyproject_metadata.StandardMetadata.from_pyproject( 1506 | { 1507 | "project": { 1508 | "name": "example", 1509 | "version": "1.2.3", 1510 | "dynamic": [ 1511 | "requires-python", 1512 | ], 1513 | }, 1514 | } 1515 | ) 1516 | metadata.requires_python = packaging.specifiers.SpecifierSet(">=3.12") 1517 | metadata.version = packaging.version.Version("1.2.3") 1518 | 1519 | 1520 | def test_missing_keys_warns() -> None: 1521 | with pytest.warns( 1522 | pyproject_metadata.errors.ConfigurationWarning, 1523 | match=re.escape("Extra keys present in \"project\": 'not-real-key'"), 1524 | ): 1525 | pyproject_metadata.StandardMetadata.from_pyproject( 1526 | { 1527 | "project": { 1528 | "name": "example", 1529 | "version": "1.2.3", 1530 | "not-real-key": True, 1531 | }, 1532 | } 1533 | ) 1534 | 1535 | 1536 | def test_missing_keys_okay() -> None: 1537 | pyproject_metadata.StandardMetadata.from_pyproject( 1538 | { 1539 | "project": {"name": "example", "version": "1.2.3", "not-real-key": True}, 1540 | }, 1541 | allow_extra_keys=True, 1542 | ) 1543 | 1544 | 1545 | def test_extra_top_level() -> None: 1546 | assert not pyproject_metadata.extras_top_level( 1547 | {"project": {}, "dependency-groups": {}} 1548 | ) 1549 | assert {"also-not-real", "not-real"} == pyproject_metadata.extras_top_level( 1550 | { 1551 | "not-real": {}, 1552 | "also-not-real": {}, 1553 | "project": {}, 1554 | "build-system": {}, 1555 | } 1556 | ) 1557 | 1558 | 1559 | def test_extra_build_system() -> None: 1560 | assert not pyproject_metadata.extras_build_system( 1561 | { 1562 | "build-system": { 1563 | "build-backend": "one", 1564 | "requires": ["two"], 1565 | "backend-path": "local", 1566 | }, 1567 | } 1568 | ) 1569 | assert {"also-not-real", "not-real"} == pyproject_metadata.extras_build_system( 1570 | { 1571 | "build-system": { 1572 | "not-real": {}, 1573 | "also-not-real": {}, 1574 | } 1575 | } 1576 | ) 1577 | 1578 | 1579 | def test_multiline_description_warns() -> None: 1580 | with pytest.warns( 1581 | pyproject_metadata.errors.ConfigurationWarning, 1582 | match=re.escape( 1583 | 'The one-line summary "project.description" should not contain more than one line. Readers might merge or truncate newlines.' 1584 | ), 1585 | ): 1586 | pyproject_metadata.StandardMetadata.from_pyproject( 1587 | { 1588 | "project": { 1589 | "name": "example", 1590 | "version": "1.2.3", 1591 | "description": "this\nis multiline", 1592 | }, 1593 | } 1594 | ) 1595 | --------------------------------------------------------------------------------