pax_global_header00006660000000000000000000000064147636615430014531gustar00rootroot0000000000000052 comment=81b1d0558c7165e09b717dcbccb6a3dc4f814887 pyproject-metadata-0.9.1/000077500000000000000000000000001476366154300153355ustar00rootroot00000000000000pyproject-metadata-0.9.1/.github/000077500000000000000000000000001476366154300166755ustar00rootroot00000000000000pyproject-metadata-0.9.1/.github/codecov.yml000066400000000000000000000002031476366154300210350ustar00rootroot00000000000000comment: false coverage: status: project: default: target: 100% patch: default: target: 100% pyproject-metadata-0.9.1/.github/dependabot.yml000066400000000000000000000003401476366154300215220ustar00rootroot00000000000000version: 2 updates: # Maintain dependencies for GitHub Actions - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" groups: actions: patterns: - "*" pyproject-metadata-0.9.1/.github/release.yml000066400000000000000000000001141476366154300210340ustar00rootroot00000000000000changelog: exclude: authors: - dependabot - pre-commit-ci pyproject-metadata-0.9.1/.github/workflows/000077500000000000000000000000001476366154300207325ustar00rootroot00000000000000pyproject-metadata-0.9.1/.github/workflows/checks.yml000066400000000000000000000006101476366154300227120ustar00rootroot00000000000000name: checks on: push: branches: - main - v* pull_request: jobs: mypy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install nox run: pipx install nox - name: Setup Python uses: actions/setup-python@v5 with: python-version: 3.8 - name: Run check for type run: nox -s mypy pyproject-metadata-0.9.1/.github/workflows/release.yml000066400000000000000000000022101476366154300230700ustar00rootroot00000000000000name: Package on: workflow_dispatch: pull_request: push: branches: - main - v* release: types: - published jobs: # Always build & lint package. build-package: name: Build & verify runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: hynek/build-and-inspect-python-package@v2 # Upload to real PyPI on GitHub Releases. release-pypi: name: Publish to pypi.org environment: release runs-on: ubuntu-latest needs: build-package if: github.event_name == 'release' && github.event.action == 'published' permissions: id-token: write attestations: write steps: - name: Download packages built by build-and-inspect-python-package uses: actions/download-artifact@v4 with: name: Packages path: dist - name: Generate artifact attestation for sdist and wheel uses: actions/attest-build-provenance@bd77c077858b8d561b7a36cbe48ef4cc642ca39d # v2.2.2 with: subject-path: "dist/pyproject*" - name: Upload package to PyPI uses: pypa/gh-action-pypi-publish@release/v1 pyproject-metadata-0.9.1/.github/workflows/tests.yml000066400000000000000000000037671476366154300226340ustar00rootroot00000000000000name: tests on: workflow_dispatch: pull_request: push: branches: - main concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: pytest: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: - "ubuntu-latest" python: - "3.8" - "3.9" - "3.10" - "3.11" - "3.12" - "3.13" - "3.14" include: - os: macos-13 python: "3.7" - os: macos-14 python: "3.12" - os: ubuntu-latest python: "pypy-3.10" - os: windows-latest python: "3.8" - os: windows-latest python: "3.11" - os: windows-latest python: "3.13" - os: ubuntu-22.04 python: "3.8" steps: - name: Checkout uses: actions/checkout@v4 - name: Set up target Python uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} allow-prereleases: true - name: Install the latest version of uv uses: astral-sh/setup-uv@v5 - name: Limit virtualenv on 3.7 if: matrix.python == '3.7' run: pipx inject --force nox 'virtualenv<20.27.0' - name: Run tests run: uv run noxfile.py -s test-${{ matrix.python }} - name: Send coverage report uses: codecov/codecov-action@v5 env: PYTHON: ${{ matrix.python }} with: flags: tests env_vars: PYTHON name: ${{ matrix.python }} # https://github.com/marketplace/actions/alls-green#why required-checks-pass: # This job does nothing and is only used for the branch protection if: always() needs: - pytest runs-on: ubuntu-latest steps: - name: Decide whether the needed jobs succeeded or failed uses: re-actors/alls-green@release/v1 with: jobs: ${{ toJSON(needs) }} pyproject-metadata-0.9.1/.gitignore000066400000000000000000000060061476366154300173270ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/#use-with-ide .pdm.toml # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ pyproject-metadata-0.9.1/.pre-commit-config.yaml000066400000000000000000000042271476366154300216230ustar00rootroot00000000000000ci: autofix_prs: false autoupdate_commit_msg: "pre-commit: bump repositories" exclude: ^tests/packages/broken_license/LICENSE$ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: check-ast - id: check-builtin-literals - id: check-docstring-first - id: check-merge-conflict - id: check-yaml - id: check-toml - id: debug-statements - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit rev: "v0.9.10" hooks: - id: ruff args: ["--fix", "--show-fixes"] - id: ruff-format - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.10.0 hooks: - id: rst-backticks - id: rst-directive-colons - id: rst-inline-touching-normal - repo: https://github.com/adamchainz/blacken-docs rev: 1.19.1 hooks: - id: blacken-docs additional_dependencies: [black==24.*] - repo: https://github.com/rbubley/mirrors-prettier rev: "v3.5.3" hooks: - id: prettier types_or: [yaml, markdown, html, css, scss, javascript, json] args: [--prose-wrap=always] - repo: https://github.com/henryiii/check-sdist rev: "v1.2.0" hooks: - id: check-sdist args: [--inject-junk] additional_dependencies: - flit-core - repo: https://github.com/codespell-project/codespell rev: v2.4.1 hooks: - id: codespell exclude: ^(LICENSE$|src/scikit_build_core/resources/find_python|tests/test_skbuild_settings.py$) - repo: https://github.com/shellcheck-py/shellcheck-py rev: v0.10.0.1 hooks: - id: shellcheck - repo: https://github.com/henryiii/validate-pyproject-schema-store rev: 2025.03.03 hooks: - id: validate-pyproject - repo: https://github.com/python-jsonschema/check-jsonschema rev: 0.31.3 hooks: - id: check-dependabot - id: check-github-workflows - id: check-readthedocs - id: check-metaschema files: \.schema\.json - repo: https://github.com/scientific-python/cookie rev: 2025.01.22 hooks: - id: sp-repo-review pyproject-metadata-0.9.1/.readthedocs.yml000066400000000000000000000002271476366154300204240ustar00rootroot00000000000000version: 2 build: os: ubuntu-22.04 tools: python: "3.12" python: install: - method: pip path: . extra_requirements: [docs] pyproject-metadata-0.9.1/LICENSE000066400000000000000000000021311476366154300163370ustar00rootroot00000000000000Copyright © 2019 Filipe Laíns Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. pyproject-metadata-0.9.1/README.md000066400000000000000000000124711476366154300166210ustar00rootroot00000000000000# pyproject-metadata [![pre-commit.ci status][pre-commit-badge]][pre-commit-link] [![checks][gha-checks-badge]][gha-checks-link] [![tests][gha-tests-badge]][gha-tests-link] [![codecov][codecov-badge]][codecov-link] [![Documentation Status][rtd-badge]][rtd-link] [![PyPI version][pypi-version]][pypi-link] > Dataclass for PEP 621 metadata with support for [core metadata] generation This project does not implement the parsing of `pyproject.toml` containing PEP 621 metadata. Instead, given a Python data structure representing PEP 621 metadata (already parsed), it will validate this input and generate a PEP 643-compliant metadata file (e.g. `PKG-INFO`). ## Usage After [installing `pyproject-metadata`](https://pypi.org/project/pyproject-metadata/), you can use it as a library in your scripts and programs: ```python from pyproject_metadata import StandardMetadata parsed_pyproject = {...} # you can use parsers like `tomli` to obtain this dict metadata = StandardMetadata.from_pyproject(parsed_pyproject, allow_extra_keys=False) print(metadata.entrypoints) # same fields as defined in PEP 621 pkg_info = metadata.as_rfc822() print(str(pkg_info)) # core metadata ``` ## SPDX licenses (METADATA 2.4+) If `project.license` is a string or `project.license-files` is present, then METADATA 2.4+ will be used. A user is expected to validate and normalize `metadata.license` with an SPDX validation tool, such as the one being added to `packaging`. Add something like this (requires packaging 24.2+): ```python if isinstance(metadata.license, str): metadata.license = packaging.licenses.canonicalize_license_expression( metadata.license ) ``` A backend is also expected to copy entries from `project.licence_files`, which are paths relative to the project directory, into the `dist-info/licenses` folder, preserving the original source structure. ## Dynamic Metadata (METADATA 2.2+) Pyproject-metadata supports dynamic metadata. To use it, specify your METADATA fields in `dynamic_metadata`. If you want to convert `pyproject.toml` field names to METADATA field(s), use `pyproject_metadata.pyproject_to_metadata("field-name")`, which will return a frozenset of metadata names that are touched by that field. ## Adding extra fields You can add extra fields to the Message returned by `to_rfc822()`, as long as they are valid metadata entries. ## Collecting multiple errors You can use the `all_errors` argument to `from_pyproject` to show all errors in the metadata parse at once, instead of raising an exception on the first one. The exception type will be `pyproject_metadata.errors.ExceptionGroup` (which is just `ExceptionGroup` on Python 3.11+). ## Validating extra fields By default, a warning (`pyproject_metadata.errors.ExtraKeyWarning`) will be issued for extra fields at the project table. You can pass `allow_extra_keys=` to either avoid the check (`True`) or hard error (`False`). If you want to detect extra keys, you can get them with `pyproject_metadata.extra_top_level` and `pyproject_metadata.extra_build_system`. It is recommended that build systems only warn on failures with these extra keys. ## Validating classifiers If you want to validate classifiers, then install the `trove_classifiers` library (the canonical source for classifiers), and run: ```python import trove_classifiers metadata_classifieres = { c for c in metadata.classifiers if not c.startswith("Private ::") } invalid_classifiers = set(metadata.classifiers) - trove_classifiers.classifiers # Also the deprecated dict if you want it dep_names = set(metadata.classifiers) & set(trove_classifiers.deprecated_classifiers) deprecated_classifiers = { k: trove_classifiers.deprecated_classifiers[k] for k in dep_names } ``` If you are writing a build backend, you should not validate classifiers with a `Private ::` prefix; these are only restricted for upload to PyPI (such as `Private :: Do Not Upload`). Since classifiers are a moving target, it is probably best for build backends (which may be shipped by third party distributors like Debian or Fedora) to either ignore or have optional classifier validation. [core metadata]: https://packaging.python.org/specifications/core-metadata/ [gha-checks-link]: https://github.com/pypa/pyproject-metadata/actions/workflows/checks.yml [gha-checks-badge]: https://github.com/pypa/pyproject-metadata/actions/workflows/checks.yml/badge.svg [gha-tests-link]: https://github.com/pypa/pyproject-metadata/actions/workflows/tests.yml [gha-tests-badge]: https://github.com/pypa/pyproject-metadata/actions/workflows/tests.yml/badge.svg [pre-commit-link]: https://results.pre-commit.ci/latest/github/pypa/pyproject-metadata/main [pre-commit-badge]: https://results.pre-commit.ci/badge/github/pypa/pyproject-metadata/main.svg [codecov-link]: https://codecov.io/gh/pypa/pyproject-metadata [codecov-badge]: https://codecov.io/gh/pypa/pyproject-metadata/branch/main/graph/badge.svg?token=9chBjS1lch [pypi-link]: https://pypi.org/project/pyproject-metadata/ [pypi-version]: https://badge.fury.io/py/pyproject-metadata.svg [rtd-link]: https://pep621.readthedocs.io/en/latest/?badge=latest [rtd-badge]: https://readthedocs.org/projects/pep621/badge/?version=latest pyproject-metadata-0.9.1/docs/000077500000000000000000000000001476366154300162655ustar00rootroot00000000000000pyproject-metadata-0.9.1/docs/api/000077500000000000000000000000001476366154300170365ustar00rootroot00000000000000pyproject-metadata-0.9.1/docs/api/pyproject_metadata.rst000066400000000000000000000013071476366154300234500ustar00rootroot00000000000000API Reference ============= .. automodule:: pyproject_metadata :members: :undoc-members: :show-inheritance: :exclude-members: ConfigurationError Submodules ---------- pyproject\_metadata.constants module ------------------------------------ .. automodule:: pyproject_metadata.constants :members: :undoc-members: :show-inheritance: pyproject\_metadata.errors module --------------------------------- .. automodule:: pyproject_metadata.errors :members: :undoc-members: :show-inheritance: pyproject\_metadata.project\_table module ----------------------------------------- .. automodule:: pyproject_metadata.project_table :members: :undoc-members: :show-inheritance: pyproject-metadata-0.9.1/docs/changelog.md000066400000000000000000000112731476366154300205420ustar00rootroot00000000000000# Changelog ## Unreleased - Remove Python 3.7 support ## 0.9.1 (10-03-2024) This release fixes form feeds in License files using pre-PEP 639 syntax when using Python older than 3.12.4; this is a regression in 0.9.0 from moving to the standard library email module. Some other small fixes to validation messages were applied. Fixes: - Handle form feed for Python <3.12.4 - Some touchup based on packaging PR Docs: - Fix `packaging.licenses` example code Internal and CI: - Speed up CI a bit, add Python 3.14 alpha testing ## 0.9.0 (22-10-2024) This release adds PEP 639 support (METADATA 2.4), refactors the RFC messages, and adds a lot of validation (including warnings and opt-in errors), a way to produce all validation errors at once, and more. The beta releases are intended for backend authors to try out the changes before a final release. Features: - Added PEP 639 support for SPDX license and license files, METADATA 2.4 - Validate extra keys (warning, opt-in error) - Functions to check top level and build-system (including PEP 735 support) - Add TypedDict's in new module for typing pyproject.toml dicts - `all_errors=True` causes `ExceptionGroup`'s to be emitted - Support METADATA 2.1+ JSON format with new `.as_json()` method Fixes: - Match EmailMessage spacing - Handle multilines the way setuptools does with smart indentation - Warn on multiline Summary (`project.description`) - Improve locking for just metadata fields - Error on extra keys in author/maintainer - URL name stylization removed matching PEP 753 Refactoring: - Move fetcher methods - Put validation in method - Make `RFC822Message` compatible with and subclass of `EmailMessage` class with support for Unicode - Remove indirection accessing `metadata_version`, add `auto_metadata_version` - Rework how dynamic works, add `dynamic_metadata` - Use dataclass instead of named tuple - Use named arguments instead of positional - Spit up over multiple files - Remove `DataFetcher`, use static types wherever possible - Reformat single quotes to double quotes to match packaging - Produce standard Python repr style in error messages (keeping double quotes for key names) - Show the types instead of values in error messages Internal and CI: - Better changelog auto-generation - `macos-latest` now points at `macos-14` - Refactor and cleanup tests - Add human readable IDs to tests - Require 100% coverage Docs: - Include extra badge in readme - Rework docs, include README and more classes - Changelog is now in markdown - Better API section ## 0.8.1 (07-10-2024) - Validate project name - Validate entrypoint group names - Correct typing for emails - Add 3.13 to testing - Add ruff-format - Actions and dependabot - Generate GitHub attestations for releases - Add PyPI attestations - Fix coverage context ## 0.8.0 (17-04-2024) - Support specifying the `metadata_version` as 2.1, 2.2, or 2.3 - Always normalize extras following PEP 685 - Preserve the user-specified name style in the metadata. `.canonical_name` added to get the normalized name - Require "version" in the dynamic table if unset (following PEP 621) - Support extras using markers containing "or" - Support empty extras - Using `.as_rfc822()` no longer modifies the metadata object - Fix email-author listing for names containing commas - Separate core metadata keywords with commas, following the (modified) spec - An error message reported `project.license` instead of `project.readme` - Produce slightly cleaner tracebacks Fix a typo in an exception message - Subclasses now type check correctly - The build backend is now `flit-core` ## 0.7.1 (30-01-2023) - Relax `pypa/packaging` dependency ## 0.7.0 (18-01-2023) - Use UTF-8 when opening files - Use `tomllib` on Python \>= 3.11 ## 0.6.1 (07-07-2022) - Avoid first and last newlines in license contents ## 0.6.0 (06-07-2022) - Make license and readme files `pathlib.Path` instances - Add the license contents to the metadata file - Add support for multiline data in metadata fields ## 0.5.0 (09-06-2022) - Renamed project to `pyproject_metadata` - Support multiple clauses in requires-python - Error out when dynamic fields are defined - Update dynamic field when setting version ## 0.4.0 (30-09-2021) - Use Core Metadata 2.1 if possible - Fix bug preventing empty README and license files from being used ## 0.3.1 (25-09-2021) - Avoid core metadata `Author`/`Maintainer` fields in favor of `Author-Email`/`Maintainer-Email` ## 0.3.0.post2 (15-09-2021) - Fix Python version requirement ## 0.3.0.post1 (13-09-2021) - Add documentation ## 0.3.0 (13-09-2021) - Added `RFC822Message` - Refactor `StandardMetadata` as a dataclass - Added `StandardMetadata.write_to_rfc822` and `StandardMetadata.as_rfc822` ## 0.1.0 (25-08-2021) - Initial release pyproject-metadata-0.9.1/docs/conf.py000066400000000000000000000042401476366154300175640ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. import pyproject_metadata # -- Project information ----------------------------------------------------- project = "pyproject-metadata" copyright = "2021, Filipe Laíns" author = "Filipe Laíns" # The short X.Y version version = pyproject_metadata.__version__ # The full version, including alpha/beta/rc tags release = pyproject_metadata.__version__ # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ "myst_parser", "sphinx.ext.autodoc", "sphinx.ext.intersphinx", "sphinx.ext.viewcode", "sphinx_autodoc_typehints", ] intersphinx_mapping = { "python": ("https://docs.python.org/3/", None), "packaging": ("https://packaging.pypa.io/en/latest/", None), } # Add any paths that contain templates here, relative to this directory. templates_path = [] source_suffix = [".rst", ".md"] # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = "furo" html_title = f"pyproject-metadata {version}" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named 'default.css' will overwrite the builtin 'default.css'. # html_static_path = ['_static'] autodoc_default_options = { "member-order": "bysource", } autoclass_content = "both" # Type hints always_use_bars_union = True pyproject-metadata-0.9.1/docs/index.md000066400000000000000000000004501476366154300177150ustar00rootroot00000000000000```{include} ../README.md ``` ```{toctree} :caption: Contents: :maxdepth: 2 api/pyproject_metadata changelog ``` ```{toctree} :caption: Links: :hidden: Source Code Issue Tracker ``` pyproject-metadata-0.9.1/noxfile.py000066400000000000000000000042341476366154300173560ustar00rootroot00000000000000# SPDX-License-Identifier: MIT # /// script # dependencies = ["nox >=2025.2.9"] # /// import argparse import os import os.path import nox nox.needs_version = ">=2025.2.9" nox.options.reuse_existing_virtualenvs = True nox.options.default_venv_backend = "uv|virtualenv" ALL_PYTHONS = nox.project.python_versions(nox.project.load_toml("pyproject.toml")) ALL_PYTHONS += ["3.14", "pypy-3.10"] @nox.session(python="3.8") def mypy(session: nox.Session) -> None: """ Run a type checker. """ session.install(".", "mypy", "nox", "pytest") session.run("mypy", "pyproject_metadata", "tests", "noxfile.py") @nox.session(python=ALL_PYTHONS) def test(session: nox.Session) -> None: """ Run the test suite. """ htmlcov_output = os.path.join(session.virtualenv.location, "htmlcov") xmlcov_output = os.path.join( session.virtualenv.location, f"coverage-{session.python}.xml" ) session.install("-e.[test]") session.run( "pytest", "--cov", f"--cov-report=html:{htmlcov_output}", f"--cov-report=xml:{xmlcov_output}", "--cov-report=term-missing", "--cov-context=test", "tests/", *session.posargs, ) @nox.session(default=False) def docs(session: nox.Session) -> None: """ Build the docs. Use "--non-interactive" to avoid serving. Pass "-b linkcheck" to check links. """ parser = argparse.ArgumentParser() parser.add_argument( "-b", dest="builder", default="html", help="Build target (default: html)" ) args, posargs = parser.parse_known_args(session.posargs) serve = args.builder == "html" and session.interactive extra_installs = ["sphinx-autobuild"] if serve else [] session.install("-e.[docs]", *extra_installs) session.chdir("docs") shared_args = ( "-n", # nitpicky mode "-T", # full tracebacks f"-b={args.builder}", ".", f"_build/{args.builder}", *posargs, ) if serve: session.run("sphinx-autobuild", "--open-browser", *shared_args) else: session.run("sphinx-build", "--keep-going", *shared_args) if __name__ == "__main__": nox.main() pyproject-metadata-0.9.1/pyproject.toml000066400000000000000000000056631476366154300202630ustar00rootroot00000000000000[build-system] requires = ["flit-core"] build-backend = "flit_core.buildapi" [project] name = "pyproject-metadata" dynamic = ["version"] description = "PEP 621 metadata parsing" readme = "README.md" requires-python = ">=3.7" authors = [ { name = "Filipe Laíns", email = "lains@riseup.net" }, ] classifiers = [ "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", ] dependencies = [ "packaging>=19.0", "typing_extensions; python_version<'3.8'", ] [project.optional-dependencies] docs = [ "furo>=2023.9.10", "sphinx-autodoc-typehints>=1.10.0", "sphinx>=7.0", "sphinx-autodoc-typehints", "myst-parser", ] test = [ "pytest-cov[toml]>=2", "pytest>=6.2.4", 'tomli>=1.0.0;python_version<"3.11"', 'exceptiongroup;python_version<"3.11"', # Optional ] [project.urls] changelog = "https://pep621.readthedocs.io/en/stable/changelog.html" homepage = "https://github.com/pypa/pyproject-metadata" [tool.flit.sdist] include = ["LICENSE", "tests/**", "docs/**", ".gitignore"] [tool.uv] dev-dependencies = ["pyproject-metadata[test]"] environments = [ "python_version >= '3.10'", ] [tool.pytest.ini_options] minversion = "6.0" addopts = ["-ra", "--showlocals", "--strict-markers", "--strict-config"] xfail_strict = true filterwarnings = ["error"] log_cli_level = "info" testpaths = ["tests"] [tool.mypy] strict = true warn_unreachable = false enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] [tool.ruff.lint] extend-select = [ "C90", # mccabe "B", # flake8-bugbear "I", # isort "ARG", # flake8-unused-arguments "C4", # flake8-comprehensions "ICN", # flake8-import-conventions "ISC", # flake8-implicit-str-concat "EM", # flake8-errmsg "G", # flake8-logging-format "PGH", # pygrep-hooks "PIE", # flake8-pie "PL", # pylint "PT", # flake8-pytest-style "RET", # flake8-return "RUF", # Ruff-specific "SIM", # flake8-simplify "T20", # flake8-print "UP", # pyupgrade "YTT", # flake8-2020 "EXE", # flake8-executable "NPY", # NumPy specific rules "PD", # pandas-vet ] ignore = [ "ISC001", # conflicts with formatter "PLR09", # Design related (too many X) "PLR2004", # Magic value in comparison ] [tool.ruff.format] docstring-code-format = true [tool.coverage] html.show_contexts = true report.exclude_also = [ "if typing.TYPE_CHECKING:", ] [tool.repo-review] ignore = ["PC140"] pyproject-metadata-0.9.1/pyproject_metadata/000077500000000000000000000000001476366154300212145ustar00rootroot00000000000000pyproject-metadata-0.9.1/pyproject_metadata/__init__.py000066400000000000000000000631331476366154300233330ustar00rootroot00000000000000# SPDX-License-Identifier: MIT """ This is pyproject_metadata, a library for working with PEP 621 metadata. Example usage: .. code-block:: python from pyproject_metadata import StandardMetadata metadata = StandardMetadata.from_pyproject( parsed_pyproject, allow_extra_keys=False, all_errors=True, metadata_version="2.3" ) pkg_info = metadata.as_rfc822() with open("METADATA", "wb") as f: f.write(pkg_info.as_bytes()) ep = self.metadata.entrypoints.copy() ep["console_scripts"] = self.metadata.scripts ep["gui_scripts"] = self.metadata.gui_scripts for group, entries in ep.items(): if entries: with open("entry_points.txt", "w", encoding="utf-8") as f: print(f"[{group}]", file=f) for name, target in entries.items(): print(f"{name} = {target}", file=f) print(file=f) """ from __future__ import annotations import copy import dataclasses import email.message import email.policy import email.utils import os import os.path import pathlib import sys import typing import warnings # Build backends may vendor this package, so all imports are relative. from . import constants from .errors import ConfigurationError, ConfigurationWarning, ErrorCollector from .pyproject import License, PyProjectReader, Readme if typing.TYPE_CHECKING: from collections.abc import Mapping from typing import Any from packaging.requirements import Requirement if sys.version_info < (3, 11): from typing_extensions import Self else: from typing import Self from .project_table import Dynamic, PyProjectTable import packaging.markers import packaging.specifiers import packaging.utils import packaging.version if sys.version_info < (3, 12, 4): import re RE_EOL_STR = re.compile(r"[\r\n]+") RE_EOL_BYTES = re.compile(rb"[\r\n]+") __version__ = "0.9.1" __all__ = [ "ConfigurationError", "License", "RFC822Message", "RFC822Policy", "Readme", "StandardMetadata", "extras_build_system", "extras_project", "extras_top_level", "field_to_metadata", ] def __dir__() -> list[str]: return __all__ def field_to_metadata(field: str) -> frozenset[str]: """ Return the METADATA fields that correspond to a project field. """ return frozenset(constants.PROJECT_TO_METADATA[field]) def extras_top_level(pyproject_table: Mapping[str, Any]) -> set[str]: """ Return any extra keys in the top-level of the pyproject table. """ return set(pyproject_table) - constants.KNOWN_TOPLEVEL_FIELDS def extras_build_system(pyproject_table: Mapping[str, Any]) -> set[str]: """ Return any extra keys in the build-system table. """ return ( set(pyproject_table.get("build-system", [])) - constants.KNOWN_BUILD_SYSTEM_FIELDS ) def extras_project(pyproject_table: Mapping[str, Any]) -> set[str]: """ Return any extra keys in the project table. """ return set(pyproject_table.get("project", [])) - constants.KNOWN_PROJECT_FIELDS @dataclasses.dataclass class _SmartMessageSetter: """ This provides a nice internal API for setting values in an Message to reduce boilerplate. If a value is None, do nothing. """ message: email.message.Message def __setitem__(self, name: str, value: str | None) -> None: if not value: return self.message[name] = value def set_payload(self, payload: str) -> None: self.message.set_payload(payload) @dataclasses.dataclass class _JSonMessageSetter: """ This provides an API to build a JSON message output in the same way as the classic Message. Line breaks are preserved this way. """ data: dict[str, str | list[str]] def __setitem__(self, name: str, value: str | None) -> None: name = name.lower() key = name.replace("-", "_") if value is None: return if name == "keywords": values = (x.strip() for x in value.split(",")) self.data[key] = [x for x in values if x] elif name in constants.KNOWN_MULTIUSE: entry = self.data.setdefault(key, []) assert isinstance(entry, list) entry.append(value) else: self.data[key] = value def set_payload(self, payload: str) -> None: self["description"] = payload class RFC822Policy(email.policy.EmailPolicy): """ This is :class:`email.policy.EmailPolicy`, but with a simple ``header_store_parse`` implementation that handles multiline values, and some nice defaults. """ utf8 = True mangle_from_ = False max_line_length = 0 def header_store_parse(self, name: str, value: str) -> tuple[str, str]: if name.lower() not in constants.KNOWN_METADATA_FIELDS: msg = f"Unknown field {name!r}" raise ConfigurationError(msg, key=name) size = len(name) + 2 value = value.replace("\n", "\n" + " " * size) return (name, value) if sys.version_info < (3, 12, 4): # Work around Python bug https://github.com/python/cpython/issues/117313 def _fold( self, name: str, value: Any, refold_binary: bool = False ) -> str: # pragma: no cover if hasattr(value, "name"): return value.fold(policy=self) # type: ignore[no-any-return] maxlen = self.max_line_length if self.max_line_length else sys.maxsize # this is from the library version, and it improperly breaks on chars like 0x0c, treating # them as 'form feed' etc. # we need to ensure that only CR/LF is used as end of line # lines = value.splitlines() # this is a workaround which splits only on CR/LF characters if isinstance(value, bytes): lines = RE_EOL_BYTES.split(value) else: lines = RE_EOL_STR.split(value) refold = self.refold_source == "all" or ( self.refold_source == "long" and ( (lines and len(lines[0]) + len(name) + 2 > maxlen) or any(len(x) > maxlen for x in lines[1:]) ) ) if refold or (refold_binary and email.policy._has_surrogates(value)): # type: ignore[attr-defined] return self.header_factory(name, "".join(lines)).fold(policy=self) # type: ignore[arg-type,no-any-return] return name + ": " + self.linesep.join(lines) + self.linesep # type: ignore[arg-type] class RFC822Message(email.message.EmailMessage): """ This is :class:`email.message.EmailMessage` with two small changes: it defaults to our `RFC822Policy`, and it correctly writes unicode when being called with `bytes()`. """ def __init__(self) -> None: super().__init__(policy=RFC822Policy()) def as_bytes( self, unixfrom: bool = False, policy: email.policy.Policy | None = None ) -> bytes: """ This handles unicode encoding. """ return self.as_string(unixfrom, policy=policy).encode("utf-8") @dataclasses.dataclass class StandardMetadata: """ This class represents the standard metadata fields for a project. It can be used to read metadata from a pyproject.toml table, validate it, and write it to an RFC822 message or JSON. """ name: str version: packaging.version.Version | None = None description: str | None = None license: License | str | None = None license_files: list[pathlib.Path] | None = None readme: Readme | None = None requires_python: packaging.specifiers.SpecifierSet | None = None dependencies: list[Requirement] = dataclasses.field(default_factory=list) optional_dependencies: dict[str, list[Requirement]] = dataclasses.field( default_factory=dict ) entrypoints: dict[str, dict[str, str]] = dataclasses.field(default_factory=dict) authors: list[tuple[str, str | None]] = dataclasses.field(default_factory=list) maintainers: list[tuple[str, str | None]] = dataclasses.field(default_factory=list) urls: dict[str, str] = dataclasses.field(default_factory=dict) classifiers: list[str] = dataclasses.field(default_factory=list) keywords: list[str] = dataclasses.field(default_factory=list) scripts: dict[str, str] = dataclasses.field(default_factory=dict) gui_scripts: dict[str, str] = dataclasses.field(default_factory=dict) dynamic: list[Dynamic] = dataclasses.field(default_factory=list) """ This field is used to track dynamic fields. You can't set a field not in this list. """ dynamic_metadata: list[str] = dataclasses.field(default_factory=list) """ This is a list of METADATA fields that can change in between SDist and wheel. Requires metadata_version 2.2+. """ metadata_version: str | None = None """ This is the target metadata version. If None, it will be computed as a minimum based on the fields set. """ all_errors: bool = False """ If True, all errors will be collected and raised in an ExceptionGroup. """ def __post_init__(self) -> None: self.validate() @property def auto_metadata_version(self) -> str: """ This computes the metadata version based on the fields set in the object if ``metadata_version`` is None. """ if self.metadata_version is not None: return self.metadata_version if isinstance(self.license, str) or self.license_files is not None: return "2.4" if self.dynamic_metadata: return "2.2" return "2.1" @property def canonical_name(self) -> str: """ Return the canonical name of the project. """ return packaging.utils.canonicalize_name(self.name) @classmethod def from_pyproject( # noqa: C901 cls, data: Mapping[str, Any], project_dir: str | os.PathLike[str] = os.path.curdir, metadata_version: str | None = None, dynamic_metadata: list[str] | None = None, *, allow_extra_keys: bool | None = None, all_errors: bool = False, ) -> Self: """ Read metadata from a pyproject.toml table. This is the main method for creating an instance of this class. It also supports two additional fields: ``allow_extra_keys`` to control what happens when extra keys are present in the pyproject table, and ``all_errors``, to raise all errors in an ExceptionGroup instead of raising the first one. """ pyproject = PyProjectReader(collect_errors=all_errors) pyproject_table: PyProjectTable = data # type: ignore[assignment] if "project" not in pyproject_table: msg = "Section {key} missing in pyproject.toml" pyproject.config_error(msg, key="project") pyproject.finalize("Failed to parse pyproject.toml") msg = "Unreachable code" # pragma: no cover raise AssertionError(msg) # pragma: no cover project = pyproject_table["project"] project_dir = pathlib.Path(project_dir) if not allow_extra_keys: extra_keys = extras_project(data) if extra_keys: extra_keys_str = ", ".join(sorted(f"{k!r}" for k in extra_keys)) msg = "Extra keys present in {key}: {extra_keys}" pyproject.config_error( msg, key="project", extra_keys=extra_keys_str, warn=allow_extra_keys is None, ) dynamic = pyproject.get_dynamic(project) for field in dynamic: if field in data["project"]: msg = 'Field {key} declared as dynamic in "project.dynamic" but is defined' pyproject.config_error(msg, key=f"project.{field}") raw_name = project.get("name") name = "UNKNOWN" if raw_name is None: msg = "Field {key} missing" pyproject.config_error(msg, key="project.name") else: tmp_name = pyproject.ensure_str(raw_name, "project.name") if tmp_name is not None: name = tmp_name version: packaging.version.Version | None = packaging.version.Version("0.0.0") raw_version = project.get("version") if raw_version is not None: version_string = pyproject.ensure_str(raw_version, "project.version") if version_string is not None: try: version = ( packaging.version.Version(version_string) if version_string else None ) except packaging.version.InvalidVersion: msg = "Invalid {key} value, expecting a valid PEP 440 version" pyproject.config_error( msg, key="project.version", got=version_string ) elif "version" not in dynamic: msg = ( "Field {key} missing and 'version' not specified in \"project.dynamic\"" ) pyproject.config_error(msg, key="project.version") # Description fills Summary, which cannot be multiline # However, throwing an error isn't backward compatible, # so leave it up to the users for now. project_description_raw = project.get("description") description = ( pyproject.ensure_str(project_description_raw, "project.description") if project_description_raw is not None else None ) requires_python_raw = project.get("requires-python") requires_python = None if requires_python_raw is not None: requires_python_string = pyproject.ensure_str( requires_python_raw, "project.requires-python" ) if requires_python_string is not None: try: requires_python = packaging.specifiers.SpecifierSet( requires_python_string ) except packaging.specifiers.InvalidSpecifier: msg = "Invalid {key} value, expecting a valid specifier set" pyproject.config_error( msg, key="project.requires-python", got=requires_python_string ) self = None with pyproject.collect(): self = cls( name=name, version=version, description=description, license=pyproject.get_license(project, project_dir), license_files=pyproject.get_license_files(project, project_dir), readme=pyproject.get_readme(project, project_dir), requires_python=requires_python, dependencies=pyproject.get_dependencies(project), optional_dependencies=pyproject.get_optional_dependencies(project), entrypoints=pyproject.get_entrypoints(project), authors=pyproject.ensure_people( project.get("authors", []), "project.authors" ), maintainers=pyproject.ensure_people( project.get("maintainers", []), "project.maintainers" ), urls=pyproject.ensure_dict(project.get("urls", {}), "project.urls") or {}, classifiers=pyproject.ensure_list( project.get("classifiers", []), "project.classifiers" ) or [], keywords=pyproject.ensure_list( project.get("keywords", []), "project.keywords" ) or [], scripts=pyproject.ensure_dict( project.get("scripts", {}), "project.scripts" ) or {}, gui_scripts=pyproject.ensure_dict( project.get("gui-scripts", {}), "project.gui-scripts" ) or {}, dynamic=dynamic, dynamic_metadata=dynamic_metadata or [], metadata_version=metadata_version, all_errors=all_errors, ) pyproject.finalize("Failed to parse pyproject.toml") assert self is not None return self def as_rfc822(self) -> RFC822Message: """ Return an RFC822 message with the metadata. """ message = RFC822Message() smart_message = _SmartMessageSetter(message) self._write_metadata(smart_message) return message def as_json(self) -> dict[str, str | list[str]]: """ Return a JSON message with the metadata. """ message: dict[str, str | list[str]] = {} smart_message = _JSonMessageSetter(message) self._write_metadata(smart_message) return message def validate(self, *, warn: bool = True) -> None: # noqa: C901 """ Validate metadata for consistency and correctness. Will also produce warnings if ``warn`` is given. Respects ``all_errors``. This is called when loading a pyproject.toml, and when making metadata. Checks: - ``metadata_version`` is a known version or None - ``name`` is a valid project name - ``license_files`` can't be used with classic ``license`` - License classifiers can't be used with SPDX license - ``description`` is a single line (warning) - ``license`` is not an SPDX license expression if metadata_version >= 2.4 (warning) - License classifiers deprecated for metadata_version >= 2.4 (warning) - ``license`` is an SPDX license expression if metadata_version >= 2.4 - ``license_files`` is supported only for metadata_version >= 2.4 - ``project_url`` can't contain keys over 32 characters """ errors = ErrorCollector(collect_errors=self.all_errors) if self.auto_metadata_version not in constants.KNOWN_METADATA_VERSIONS: msg = "The metadata_version must be one of {versions} or None (default)" errors.config_error(msg, versions=constants.KNOWN_METADATA_VERSIONS) try: packaging.utils.canonicalize_name(self.name, validate=True) except packaging.utils.InvalidName: msg = ( "Invalid project name {name!r}. A valid name consists only of ASCII letters and " "numbers, period, underscore and hyphen. It must start and end with a letter or number" ) errors.config_error(msg, key="project.name", name=self.name) if self.license_files is not None and isinstance(self.license, License): msg = '{key} must not be used when "project.license" is not a SPDX license expression' errors.config_error(msg, key="project.license-files") if isinstance(self.license, str) and any( c.startswith("License ::") for c in self.classifiers ): msg = "Setting {key} to an SPDX license expression is not compatible with 'License ::' classifiers" errors.config_error(msg, key="project.license") if warn: if self.description and "\n" in self.description: warnings.warn( 'The one-line summary "project.description" should not contain more than one line. Readers might merge or truncate newlines.', ConfigurationWarning, stacklevel=2, ) if self.auto_metadata_version not in constants.PRE_SPDX_METADATA_VERSIONS: if isinstance(self.license, License): warnings.warn( 'Set "project.license" to an SPDX license expression for metadata >= 2.4', ConfigurationWarning, stacklevel=2, ) elif any(c.startswith("License ::") for c in self.classifiers): warnings.warn( "'License ::' classifiers are deprecated for metadata >= 2.4, use a SPDX license expression for \"project.license\" instead", ConfigurationWarning, stacklevel=2, ) if ( isinstance(self.license, str) and self.auto_metadata_version in constants.PRE_SPDX_METADATA_VERSIONS ): msg = "Setting {key} to an SPDX license expression is supported only when emitting metadata version >= 2.4" errors.config_error(msg, key="project.license") if ( self.license_files is not None and self.auto_metadata_version in constants.PRE_SPDX_METADATA_VERSIONS ): msg = "{key} is supported only when emitting metadata version >= 2.4" errors.config_error(msg, key="project.license-files") for name in self.urls: if len(name) > 32: msg = "{key} names cannot be more than 32 characters long" errors.config_error(msg, key="project.urls", got=name) errors.finalize("Metadata validation failed") def _write_metadata( # noqa: C901 self, smart_message: _SmartMessageSetter | _JSonMessageSetter ) -> None: """ Write the metadata to the message. Handles JSON or Message. """ errors = ErrorCollector(collect_errors=self.all_errors) with errors.collect(): self.validate(warn=False) smart_message["Metadata-Version"] = self.auto_metadata_version smart_message["Name"] = self.name if not self.version: msg = "Field {key} missing" errors.config_error(msg, key="project.version") smart_message["Version"] = str(self.version) # skip 'Platform' # skip 'Supported-Platform' if self.description: smart_message["Summary"] = self.description smart_message["Keywords"] = ",".join(self.keywords) or None # skip 'Home-page' # skip 'Download-URL' smart_message["Author"] = _name_list(self.authors) smart_message["Author-Email"] = _email_list(self.authors) smart_message["Maintainer"] = _name_list(self.maintainers) smart_message["Maintainer-Email"] = _email_list(self.maintainers) if isinstance(self.license, License): smart_message["License"] = self.license.text elif isinstance(self.license, str): smart_message["License-Expression"] = self.license if self.license_files is not None: for license_file in sorted(set(self.license_files)): smart_message["License-File"] = os.fspath(license_file.as_posix()) elif ( self.auto_metadata_version not in constants.PRE_SPDX_METADATA_VERSIONS and isinstance(self.license, License) and self.license.file ): smart_message["License-File"] = os.fspath(self.license.file.as_posix()) for classifier in self.classifiers: smart_message["Classifier"] = classifier # skip 'Provides-Dist' # skip 'Obsoletes-Dist' # skip 'Requires-External' for name, url in self.urls.items(): smart_message["Project-URL"] = f"{name}, {url}" if self.requires_python: smart_message["Requires-Python"] = str(self.requires_python) for dep in self.dependencies: smart_message["Requires-Dist"] = str(dep) for extra, requirements in self.optional_dependencies.items(): norm_extra = extra.replace(".", "-").replace("_", "-").lower() smart_message["Provides-Extra"] = norm_extra for requirement in requirements: smart_message["Requires-Dist"] = str( _build_extra_req(norm_extra, requirement) ) if self.readme: if self.readme.content_type: smart_message["Description-Content-Type"] = self.readme.content_type smart_message.set_payload(self.readme.text) # Core Metadata 2.2 if self.auto_metadata_version != "2.1": for field in self.dynamic_metadata: if field.lower() in {"name", "version", "dynamic"}: msg = f"Metadata field {field!r} cannot be declared dynamic" errors.config_error(msg) if field.lower() not in constants.KNOWN_METADATA_FIELDS: msg = f"Unknown metadata field {field!r} cannot be declared dynamic" errors.config_error(msg) smart_message["Dynamic"] = field errors.finalize("Failed to write metadata") def _name_list(people: list[tuple[str, str | None]]) -> str | None: """ Build a comma-separated list of names. """ return ", ".join(name for name, email_ in people if not email_) or None def _email_list(people: list[tuple[str, str | None]]) -> str | None: """ Build a comma-separated list of emails. """ return ( ", ".join( email.utils.formataddr((name, _email)) for name, _email in people if _email ) or None ) def _build_extra_req( extra: str, requirement: Requirement, ) -> Requirement: """ Build a new requirement with an extra marker. """ requirement = copy.copy(requirement) if requirement.marker: if "or" in requirement.marker._markers: requirement.marker = packaging.markers.Marker( f"({requirement.marker}) and extra == {extra!r}" ) else: requirement.marker = packaging.markers.Marker( f"{requirement.marker} and extra == {extra!r}" ) else: requirement.marker = packaging.markers.Marker(f"extra == {extra!r}") return requirement pyproject-metadata-0.9.1/pyproject_metadata/constants.py000066400000000000000000000057711476366154300236140ustar00rootroot00000000000000# SPDX-License-Identifier: MIT """ Constants for the pyproject_metadata package, collected here to make them easy to update. These should be considered mostly private. """ from __future__ import annotations __all__ = [ "KNOWN_BUILD_SYSTEM_FIELDS", "KNOWN_METADATA_FIELDS", "KNOWN_METADATA_VERSIONS", "KNOWN_METADATA_VERSIONS", "KNOWN_MULTIUSE", "KNOWN_PROJECT_FIELDS", "KNOWN_TOPLEVEL_FIELDS", "PRE_SPDX_METADATA_VERSIONS", "PROJECT_TO_METADATA", ] def __dir__() -> list[str]: return __all__ KNOWN_METADATA_VERSIONS = {"2.1", "2.2", "2.3", "2.4"} PRE_SPDX_METADATA_VERSIONS = {"2.1", "2.2", "2.3"} PROJECT_TO_METADATA = { "authors": frozenset(["Author", "Author-Email"]), "classifiers": frozenset(["Classifier"]), "dependencies": frozenset(["Requires-Dist"]), "description": frozenset(["Summary"]), "dynamic": frozenset(), "entry-points": frozenset(), "gui-scripts": frozenset(), "keywords": frozenset(["Keywords"]), "license": frozenset(["License", "License-Expression"]), "license-files": frozenset(["License-File"]), "maintainers": frozenset(["Maintainer", "Maintainer-Email"]), "name": frozenset(["Name"]), "optional-dependencies": frozenset(["Provides-Extra", "Requires-Dist"]), "readme": frozenset(["Description", "Description-Content-Type"]), "requires-python": frozenset(["Requires-Python"]), "scripts": frozenset(), "urls": frozenset(["Project-URL"]), "version": frozenset(["Version"]), } KNOWN_TOPLEVEL_FIELDS = {"build-system", "project", "tool", "dependency-groups"} KNOWN_BUILD_SYSTEM_FIELDS = {"backend-path", "build-backend", "requires"} KNOWN_PROJECT_FIELDS = set(PROJECT_TO_METADATA) KNOWN_METADATA_FIELDS = { "author", "author-email", "classifier", "description", "description-content-type", "download-url", # Not specified via pyproject standards, deprecated by PEP 753 "dynamic", # Can't be in dynamic "home-page", # Not specified via pyproject standards, deprecated by PEP 753 "keywords", "license", "license-expression", "license-file", "maintainer", "maintainer-email", "metadata-version", "name", # Can't be in dynamic "obsoletes", # Deprecated "obsoletes-dist", # Rarely used "platform", # Not specified via pyproject standards "project-url", "provides", # Deprecated "provides-dist", # Rarely used "provides-extra", "requires", # Deprecated "requires-dist", "requires-external", # Not specified via pyproject standards "requires-python", "summary", "supported-platform", # Not specified via pyproject standards "version", # Can't be in dynamic } KNOWN_MULTIUSE = { "dynamic", "platform", "provides-extra", "supported-platform", "license-file", "classifier", "requires-dist", "requires-external", "project-url", "provides-dist", "obsoletes-dist", "requires", # Deprecated "obsoletes", # Deprecated "provides", # Deprecated } pyproject-metadata-0.9.1/pyproject_metadata/errors.py000066400000000000000000000066341476366154300231130ustar00rootroot00000000000000# SPDX-License-Identifier: MIT """ This module defines exceptions and error handling utilities. It is the recommend path to access ``ConfiguratonError``, ``ConfigurationWarning``, and ``ExceptionGroup``. For backward compatibility, ``ConfigurationError`` is re-exported in the top-level package. """ from __future__ import annotations import builtins import contextlib import dataclasses import sys import typing import warnings __all__ = [ "ConfigurationError", "ConfigurationWarning", "ExceptionGroup", ] def __dir__() -> list[str]: return __all__ class ConfigurationError(Exception): """Error in the backend metadata. Has an optional key attribute, which will be non-None if the error is related to a single key in the pyproject.toml file.""" def __init__(self, msg: str, *, key: str | None = None): super().__init__(msg) self._key = key @property def key(self) -> str | None: # pragma: no cover return self._key class ConfigurationWarning(UserWarning): """Warnings about backend metadata.""" if sys.version_info >= (3, 11): ExceptionGroup = builtins.ExceptionGroup else: class ExceptionGroup(Exception): """A minimal implementation of `ExceptionGroup` from Python 3.11. Users can replace this with a more complete implementation, such as from the exceptiongroup backport package, if better error messages and integration with tooling is desired and the addition of a dependency is acceptable. """ message: str exceptions: list[Exception] def __init__(self, message: str, exceptions: list[Exception]) -> None: self.message = message self.exceptions = exceptions def __repr__(self) -> str: return f"{self.__class__.__name__}({self.message!r}, {self.exceptions!r})" @dataclasses.dataclass class ErrorCollector: """ Collect errors and raise them as a group at the end (if collect_errors is True), otherwise raise them immediately. """ collect_errors: bool errors: list[Exception] = dataclasses.field(default_factory=list) def config_error( self, msg: str, *, key: str | None = None, got: typing.Any = None, got_type: type[typing.Any] | None = None, warn: bool = False, **kwargs: typing.Any, ) -> None: """Raise a configuration error, or add it to the error list.""" msg = msg.format(key=f'"{key}"', **kwargs) if got is not None: msg = f"{msg} (got {got!r})" if got_type is not None: msg = f"{msg} (got {got_type.__name__})" if warn: warnings.warn(msg, ConfigurationWarning, stacklevel=3) elif self.collect_errors: self.errors.append(ConfigurationError(msg, key=key)) else: raise ConfigurationError(msg, key=key) def finalize(self, msg: str) -> None: """Raise a group exception if there are any errors.""" if self.errors: raise ExceptionGroup(msg, self.errors) @contextlib.contextmanager def collect(self) -> typing.Generator[None, None, None]: """Support nesting; add any grouped errors to the error list.""" if self.collect_errors: try: yield except ExceptionGroup as error: self.errors.extend(error.exceptions) else: yield pyproject-metadata-0.9.1/pyproject_metadata/project_table.py000066400000000000000000000066121476366154300244100ustar00rootroot00000000000000# SPDX-License-Identifier: MIT """ This module contains type definitions for the tables used in the ``pyproject.toml``. You should either import this at type-check time only, or make sure ``typing_extensions`` is available for Python 3.10 and below. Documentation notice: the fields with hyphens are not shown due to a sphinx-autodoc bug. """ from __future__ import annotations import sys import typing from typing import Any, Dict, List, Union if sys.version_info < (3, 11): from typing_extensions import Required else: from typing import Required if sys.version_info < (3, 8): from typing_extensions import Literal, TypedDict else: from typing import Literal, TypedDict __all__ = [ "BuildSystemTable", "ContactTable", "Dynamic", "IncludeGroupTable", "LicenseTable", "ProjectTable", "PyProjectTable", "ReadmeTable", ] def __dir__() -> list[str]: return __all__ class ContactTable(TypedDict, total=False): name: str email: str class LicenseTable(TypedDict, total=False): text: str file: str ReadmeTable = TypedDict( "ReadmeTable", {"file": str, "text": str, "content-type": str}, total=False ) Dynamic = Literal[ "authors", "classifiers", "dependencies", "description", "dynamic", "entry-points", "gui-scripts", "keywords", "license", "maintainers", "optional-dependencies", "readme", "requires-python", "scripts", "urls", "version", ] ProjectTable = TypedDict( "ProjectTable", { "name": Required[str], "version": str, "description": str, "license": Union[LicenseTable, str], "license-files": List[str], "readme": Union[str, ReadmeTable], "requires-python": str, "dependencies": List[str], "optional-dependencies": Dict[str, List[str]], "entry-points": Dict[str, Dict[str, str]], "authors": List[ContactTable], "maintainers": List[ContactTable], "urls": Dict[str, str], "classifiers": List[str], "keywords": List[str], "scripts": Dict[str, str], "gui-scripts": Dict[str, str], "dynamic": List[Dynamic], }, total=False, ) BuildSystemTable = TypedDict( "BuildSystemTable", { "build-backend": str, "requires": List[str], "backend-path": List[str], }, total=False, ) # total=False here because this could be # extended in the future IncludeGroupTable = TypedDict( "IncludeGroupTable", {"include-group": str}, total=False, ) PyProjectTable = TypedDict( "PyProjectTable", { "build-system": BuildSystemTable, "project": ProjectTable, "tool": Dict[str, Any], "dependency-groups": Dict[str, List[Union[str, IncludeGroupTable]]], }, total=False, ) # Tests for type checking if typing.TYPE_CHECKING: PyProjectTable( { "build-system": BuildSystemTable( {"build-backend": "one", "requires": ["two"]} ), "project": ProjectTable( { "name": "one", "version": "0.1.0", } ), "tool": {"thing": object()}, "dependency-groups": { "one": [ "one", IncludeGroupTable({"include-group": "two"}), ] }, } ) pyproject-metadata-0.9.1/pyproject_metadata/py.typed000066400000000000000000000000001476366154300227010ustar00rootroot00000000000000pyproject-metadata-0.9.1/pyproject_metadata/pyproject.py000066400000000000000000000420341476366154300236100ustar00rootroot00000000000000# SPDX-License-Identifier: MIT """ This module focues on reading pyproject.toml fields with error collection. It is mostly internal, except for License and Readme classes, which are re-exported in the top-level package. """ from __future__ import annotations import dataclasses import pathlib import re import typing import packaging.requirements from .errors import ErrorCollector if typing.TYPE_CHECKING: from collections.abc import Generator, Iterable, Sequence from packaging.requirements import Requirement from .project_table import ContactTable, Dynamic, ProjectTable __all__ = [ "License", "Readme", ] def __dir__() -> list[str]: return __all__ @dataclasses.dataclass(frozen=True) class License: """ This represents a classic license, which contains text, and optionally a file path. Modern licenses are just SPDX identifiers, which are strings. """ text: str file: pathlib.Path | None @dataclasses.dataclass(frozen=True) class Readme: """ This represents a readme, which contains text and a content type, and optionally a file path. """ text: str file: pathlib.Path | None content_type: str T = typing.TypeVar("T") @dataclasses.dataclass class PyProjectReader(ErrorCollector): """Class for reading pyproject.toml fields with error collection. Unrelated errors are collected and raised at once if the `collect_errors` parameter is set to `True`. Some methods will return None if an error was raised. Most of them expect a non-None value as input to enforce the caller to handle missing vs. error correctly. The exact design is based on usage, as this is an internal class. """ def ensure_str(self, value: str, key: str) -> str | None: """Ensure that a value is a string.""" if isinstance(value, str): return value msg = "Field {key} has an invalid type, expecting a string" self.config_error(msg, key=key, got_type=type(value)) return None def ensure_list(self, val: list[T], key: str) -> list[T] | None: """Ensure that a value is a list of strings.""" if not isinstance(val, list): msg = "Field {key} has an invalid type, expecting a list of strings" self.config_error(msg, key=key, got_type=type(val)) return None for item in val: if not isinstance(item, str): msg = "Field {key} contains item with invalid type, expecting a string" self.config_error(msg, key=key, got_type=type(item)) return None return val def ensure_dict(self, val: dict[str, str], key: str) -> dict[str, str] | None: """Ensure that a value is a dictionary of strings.""" if not isinstance(val, dict): msg = "Field {key} has an invalid type, expecting a table of strings" self.config_error(msg, key=key, got_type=type(val)) return None for subkey, item in val.items(): if not isinstance(item, str): msg = "Field {key} has an invalid type, expecting a string" self.config_error(msg, key=f"{key}.{subkey}", got_type=type(item)) return None return val def ensure_people( self, val: Sequence[ContactTable], key: str ) -> list[tuple[str, str | None]]: """Ensure that a value is a list of tables with optional "name" and "email" keys.""" if not isinstance(val, list): msg = ( "Field {key} has an invalid type, expecting a list of " 'tables containing the "name" and/or "email" keys' ) self.config_error(msg, key=key, got_type=type(val)) return [] for each in val: if not isinstance(each, dict): msg = ( "Field {key} has an invalid type, expecting a list of " 'tables containing the "name" and/or "email" keys' " (got list with {type_name})" ) self.config_error(msg, key=key, type_name=type(each).__name__) return [] for value in each.values(): if not isinstance(value, str): msg = ( "Field {key} has an invalid type, expecting a list of " 'tables containing the "name" and/or "email" keys' " (got list with dict with {type_name})" ) self.config_error(msg, key=key, type_name=type(value).__name__) return [] extra_keys = set(each) - {"name", "email"} if extra_keys: msg = ( "Field {key} has an invalid type, expecting a list of " 'tables containing the "name" and/or "email" keys' " (got list with dict with extra keys {extra_keys})" ) self.config_error( msg, key=key, extra_keys=", ".join(sorted(f'"{k}"' for k in extra_keys)), ) return [] return [(entry.get("name", "Unknown"), entry.get("email")) for entry in val] def get_license( self, project: ProjectTable, project_dir: pathlib.Path ) -> License | str | None: """Get the license field from the project table. Handles PEP 639 style license too. None is returned if the license field is not present or if an error occurred. """ val = project.get("license") if val is None: return None if isinstance(val, str): return val if isinstance(val, dict): _license = self.ensure_dict(val, "project.license") # type: ignore[arg-type] if _license is None: return None else: msg = "Field {key} has an invalid type, expecting a string or table of strings" self.config_error(msg, key="project.license", got_type=type(val)) return None for field in _license: if field not in ("file", "text"): msg = "Unexpected field {key}" self.config_error(msg, key=f"project.license.{field}") return None file: pathlib.Path | None = None filename = _license.get("file") text = _license.get("text") if (filename and text) or (not filename and not text): msg = ( 'Invalid {key} contents, expecting a string or one key "file" or "text"' ) self.config_error(msg, key="project.license", got=_license) return None if filename: file = project_dir.joinpath(filename) if not file.is_file(): msg = f"License file not found ({filename!r})" self.config_error(msg, key="project.license.file") return None text = file.read_text(encoding="utf-8") assert text is not None return License(text, file) def get_license_files( self, project: ProjectTable, project_dir: pathlib.Path ) -> list[pathlib.Path] | None: """Get the license-files list of files from the project table. Returns None if an error occurred (including invalid globs, etc) or if not present. """ license_files = project.get("license-files") if license_files is None: return None if self.ensure_list(license_files, "project.license-files") is None: return None return list(self._get_files_from_globs(project_dir, license_files)) def get_readme( # noqa: C901 self, project: ProjectTable, project_dir: pathlib.Path ) -> Readme | None: """Get the text of the readme from the project table. Returns None if an error occurred or if the readme field is not present. """ if "readme" not in project: return None filename: str | None = None file: pathlib.Path | None = None text: str | None = None content_type: str | None = None readme = project["readme"] if isinstance(readme, str): # readme is a file text = None filename = readme if filename.endswith(".md"): content_type = "text/markdown" elif filename.endswith(".rst"): content_type = "text/x-rst" else: msg = "Could not infer content type for readme file {filename!r}" self.config_error(msg, key="project.readme", filename=filename) return None elif isinstance(readme, dict): # readme is a dict containing either 'file' or 'text', and content-type for field in readme: if field not in ("content-type", "file", "text"): msg = "Unexpected field {key}" self.config_error(msg, key=f"project.readme.{field}") return None content_type_raw = readme.get("content-type") if content_type_raw is not None: content_type = self.ensure_str( content_type_raw, "project.readme.content-type" ) if content_type is None: return None filename_raw = readme.get("file") if filename_raw is not None: filename = self.ensure_str(filename_raw, "project.readme.file") if filename is None: return None text_raw = readme.get("text") if text_raw is not None: text = self.ensure_str(text_raw, "project.readme.text") if text is None: return None if (filename and text) or (not filename and not text): msg = 'Invalid {key} contents, expecting either "file" or "text"' self.config_error(msg, key="project.readme", got=readme) return None if not content_type: msg = "Field {key} missing" self.config_error(msg, key="project.readme.content-type") return None else: msg = "Field {key} has an invalid type, expecting either a string or table of strings" self.config_error(msg, key="project.readme", got_type=type(readme)) return None if filename: file = project_dir.joinpath(filename) if not file.is_file(): msg = "Readme file not found ({filename!r})" self.config_error(msg, key="project.readme.file", filename=filename) return None text = file.read_text(encoding="utf-8") assert text is not None return Readme(text, file, content_type) def get_dependencies(self, project: ProjectTable) -> list[Requirement]: """Get the dependencies from the project table.""" requirement_strings: list[str] | None = None requirement_strings_raw = project.get("dependencies") if requirement_strings_raw is not None: requirement_strings = self.ensure_list( requirement_strings_raw, "project.dependencies" ) if requirement_strings is None: return [] requirements: list[Requirement] = [] for req in requirement_strings: try: requirements.append(packaging.requirements.Requirement(req)) except packaging.requirements.InvalidRequirement as e: msg = "Field {key} contains an invalid PEP 508 requirement string {req!r} ({error!r})" self.config_error(msg, key="project.dependencies", req=req, error=e) return [] return requirements def get_optional_dependencies( self, project: ProjectTable, ) -> dict[str, list[Requirement]]: """Get the optional dependencies from the project table.""" val = project.get("optional-dependencies") if not val: return {} requirements_dict: dict[str, list[Requirement]] = {} if not isinstance(val, dict): msg = "Field {key} has an invalid type, expecting a table of PEP 508 requirement strings" self.config_error( msg, key="project.optional-dependencies", got_type=type(val) ) return {} for extra, requirements in val.copy().items(): assert isinstance(extra, str) if not isinstance(requirements, list): msg = "Field {key} has an invalid type, expecting a table of PEP 508 requirement strings" self.config_error( msg, key=f"project.optional-dependencies.{extra}", got_type=type(requirements), ) return {} requirements_dict[extra] = [] for req in requirements: if not isinstance(req, str): msg = "Field {key} has an invalid type, expecting a PEP 508 requirement string" self.config_error( msg, key=f"project.optional-dependencies.{extra}", got_type=type(req), ) return {} try: requirements_dict[extra].append( packaging.requirements.Requirement(req) ) except packaging.requirements.InvalidRequirement as e: msg = ( "Field {key} contains " "an invalid PEP 508 requirement string {req!r} ({error!r})" ) self.config_error( msg, key=f"project.optional-dependencies.{extra}", req=req, error=e, ) return {} return dict(requirements_dict) def get_entrypoints(self, project: ProjectTable) -> dict[str, dict[str, str]]: """Get the entrypoints from the project table.""" val = project.get("entry-points", None) if val is None: return {} if not isinstance(val, dict): msg = "Field {key} has an invalid type, expecting a table of entrypoint sections" self.config_error(msg, key="project.entry-points", got_type=type(val)) return {} for section, entrypoints in val.items(): assert isinstance(section, str) if not re.match(r"^\w+(\.\w+)*$", section): msg = ( "Field {key} has an invalid value, expecting a name " "containing only alphanumeric, underscore, or dot characters" ) self.config_error(msg, key="project.entry-points", got=section) return {} if not isinstance(entrypoints, dict): msg = ( "Field {key} has an invalid type, expecting a table of entrypoints" ) self.config_error( msg, key=f"project.entry-points.{section}", got_type=type(entrypoints), ) return {} for name, entrypoint in entrypoints.items(): assert isinstance(name, str) if not isinstance(entrypoint, str): msg = "Field {key} has an invalid type, expecting a string" self.config_error( msg, key=f"project.entry-points.{section}.{name}", got_type=type(entrypoint), ) return {} return val def get_dynamic(self, project: ProjectTable) -> list[Dynamic]: """Get the dynamic fields from the project table. Returns an empty list if the field is not present or if an error occurred. """ dynamic = project.get("dynamic", []) self.ensure_list(dynamic, "project.dynamic") if "name" in dynamic: msg = "Unsupported field 'name' in {key}" self.config_error(msg, key="project.dynamic") return [] return dynamic def _get_files_from_globs( self, project_dir: pathlib.Path, globs: Iterable[str] ) -> Generator[pathlib.Path, None, None]: """Given a list of globs, get files that match.""" for glob in globs: if glob.startswith(("..", "/")): msg = "{glob!r} is an invalid {key} glob: the pattern must match files within the project directory" self.config_error(msg, key="project.license-files", glob=glob) break files = [f for f in project_dir.glob(glob) if f.is_file()] if not files: msg = "Every pattern in {key} must match at least one file: {glob!r} did not match any" self.config_error(msg, key="project.license-files", glob=glob) break for f in files: yield f.relative_to(project_dir) pyproject-metadata-0.9.1/tests/000077500000000000000000000000001476366154300164775ustar00rootroot00000000000000pyproject-metadata-0.9.1/tests/__init__.py000066400000000000000000000000001476366154300205760ustar00rootroot00000000000000pyproject-metadata-0.9.1/tests/packages/000077500000000000000000000000001476366154300202555ustar00rootroot00000000000000pyproject-metadata-0.9.1/tests/packages/broken_license/000077500000000000000000000000001476366154300232375ustar00rootroot00000000000000pyproject-metadata-0.9.1/tests/packages/broken_license/LICENSE000066400000000000000000000354511476366154300242540ustar00rootroot00000000000000 GNU GENERAL PUBLIC LICENSE Version 2, June 1991 Copyright (C) 1989, 1991 Free Software Foundation, Inc. 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Library General Public License instead.) You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. The precise terms and conditions for copying, distribution and modification follow. GNU GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. 1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. 4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. 6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. 7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. 10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONSpyproject-metadata-0.9.1/tests/packages/dynamic-description/000077500000000000000000000000001476366154300242225ustar00rootroot00000000000000pyproject-metadata-0.9.1/tests/packages/dynamic-description/dynamic_description.py000066400000000000000000000000001476366154300306110ustar00rootroot00000000000000pyproject-metadata-0.9.1/tests/packages/dynamic-description/pyproject.toml000066400000000000000000000001321476366154300271320ustar00rootroot00000000000000[project] name = 'dynamic-description' version = '1.0.0' dynamic = [ 'description', ] pyproject-metadata-0.9.1/tests/packages/full-metadata/000077500000000000000000000000001476366154300227755ustar00rootroot00000000000000pyproject-metadata-0.9.1/tests/packages/full-metadata/README.md000066400000000000000000000000211476366154300242450ustar00rootroot00000000000000some readme 👋 pyproject-metadata-0.9.1/tests/packages/full-metadata/full_metadata.py000066400000000000000000000000001476366154300261370ustar00rootroot00000000000000pyproject-metadata-0.9.1/tests/packages/full-metadata/pyproject.toml000066400000000000000000000022641476366154300257150ustar00rootroot00000000000000[project] name = 'full_metadata' version = '3.2.1' description = 'A package with all the metadata :)' readme = 'README.md' license = { text = 'some license text' } keywords = ['trampolim', 'is', 'interesting'] authors = [ { email = 'example@example.com' }, { name = 'Example!' }, ] maintainers = [ { name = 'Other Example', email = 'other@example.com' }, ] classifiers = [ 'Development Status :: 4 - Beta', 'Programming Language :: Python', ] requires-python = '>=3.8' dependencies = [ 'dependency1', 'dependency2>1.0.0', 'dependency3[extra]', 'dependency4; os_name != "nt"', 'dependency5[other-extra]>1.0; os_name == "nt"', ] [project.optional-dependencies] test = [ 'test_dependency', 'test_dependency[test_extra]', 'test_dependency[test_extra2] > 3.0; os_name == "nt"', ] [project.urls] homepage = 'example.com' documentation = 'readthedocs.org' repository = 'github.com/some/repo' changelog = 'github.com/some/repo/blob/master/CHANGELOG.rst' [project.scripts] full-metadata = 'full_metadata:main_cli' [project.gui-scripts] full-metadata-gui = 'full_metadata:main_gui' [project.entry-points.custom] full-metadata = 'full_metadata:main_custom' pyproject-metadata-0.9.1/tests/packages/full-metadata2/000077500000000000000000000000001476366154300230575ustar00rootroot00000000000000pyproject-metadata-0.9.1/tests/packages/full-metadata2/LICENSE000066400000000000000000000000231476366154300240570ustar00rootroot00000000000000Some license! 👋 pyproject-metadata-0.9.1/tests/packages/full-metadata2/README.rst000066400000000000000000000000211476366154300245370ustar00rootroot00000000000000some readme 👋 pyproject-metadata-0.9.1/tests/packages/full-metadata2/full_metadata2.py000066400000000000000000000000001476366154300263030ustar00rootroot00000000000000pyproject-metadata-0.9.1/tests/packages/full-metadata2/pyproject.toml000066400000000000000000000022541476366154300257760ustar00rootroot00000000000000[project] name = 'full-metadata2' version = '3.2.1' description = 'A package with all the metadata :)' readme = 'README.rst' license = { file = 'LICENSE' } keywords = ['trampolim', 'is', 'interesting'] authors = [ { email = 'example@example.com' }, { name = 'Example!' }, ] maintainers = [ { name = 'Other Example', email = 'other@example.com' }, ] classifiers = [ 'Development Status :: 4 - Beta', 'Programming Language :: Python', ] requires-python = '>=3.8' dependencies = [ 'dependency1', 'dependency2>1.0.0', 'dependency3[extra]', 'dependency4; os_name != "nt"', 'dependency5[other-extra]>1.0; os_name == "nt"', ] [project.optional-dependencies] test = [ 'test_dependency', 'test_dependency[test_extra]', 'test_dependency[test_extra2] > 3.0; os_name == "nt"', ] [project.urls] homepage = 'example.com' documentation = 'readthedocs.org' repository = 'github.com/some/repo' changelog = 'github.com/some/repo/blob/master/CHANGELOG.rst' [project.scripts] full-metadata = 'full_metadata:main_cli' [project.gui-scripts] full-metadata-gui = 'full_metadata:main_gui' [project.entry-points.custom] full-metadata = 'full_metadata:main_custom' pyproject-metadata-0.9.1/tests/packages/fulltext_license/000077500000000000000000000000001476366154300236265ustar00rootroot00000000000000pyproject-metadata-0.9.1/tests/packages/fulltext_license/LICENSE.txt000066400000000000000000000021311476366154300254460ustar00rootroot00000000000000Copyright © 2019 Filipe Laíns Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. pyproject-metadata-0.9.1/tests/packages/spdx/000077500000000000000000000000001476366154300212335ustar00rootroot00000000000000pyproject-metadata-0.9.1/tests/packages/spdx/AUTHORS.txt000066400000000000000000000000001476366154300231070ustar00rootroot00000000000000pyproject-metadata-0.9.1/tests/packages/spdx/LICENSE.md000066400000000000000000000000001476366154300226250ustar00rootroot00000000000000pyproject-metadata-0.9.1/tests/packages/spdx/LICENSE.txt000066400000000000000000000000001476366154300230440ustar00rootroot00000000000000pyproject-metadata-0.9.1/tests/packages/spdx/licenses/000077500000000000000000000000001476366154300230405ustar00rootroot00000000000000pyproject-metadata-0.9.1/tests/packages/spdx/licenses/LICENSE.MIT000066400000000000000000000000001476366154300244630ustar00rootroot00000000000000pyproject-metadata-0.9.1/tests/packages/spdx/pyproject.toml000066400000000000000000000002611476366154300241460ustar00rootroot00000000000000[project] name = "example" version = "1.2.3" license = "MIT OR GPL-2.0-or-later OR (FSFUL AND BSD-2-Clause)" license-files = ["LICEN[CS]E*", "AUTHORS*", "licenses/LICENSE.MIT"] pyproject-metadata-0.9.1/tests/packages/unknown-readme-type/000077500000000000000000000000001476366154300241665ustar00rootroot00000000000000pyproject-metadata-0.9.1/tests/packages/unknown-readme-type/README.just-made-this-up-now000066400000000000000000000000141476366154300311210ustar00rootroot00000000000000some readme pyproject-metadata-0.9.1/tests/packages/unknown-readme-type/pyproject.toml000066400000000000000000000001411476366154300270760ustar00rootroot00000000000000[project] name = 'unknown-readme-type' version = '1.0.0' readme = 'README.just-made-this-up-now' pyproject-metadata-0.9.1/tests/packages/unknown-readme-type/unknown_readme_type.py000066400000000000000000000000001476366154300306030ustar00rootroot00000000000000pyproject-metadata-0.9.1/tests/test_internals.py000066400000000000000000000012351476366154300221100ustar00rootroot00000000000000import sys import pytest import pyproject_metadata import pyproject_metadata.constants import pyproject_metadata.errors import pyproject_metadata.pyproject def test_all() -> None: assert "typing" not in dir(pyproject_metadata) assert "annotations" not in dir(pyproject_metadata.constants) assert "annotations" not in dir(pyproject_metadata.errors) assert "annotations" not in dir(pyproject_metadata.pyproject) def test_project_table_all() -> None: if sys.version_info < (3, 11): pytest.importorskip("typing_extensions") import pyproject_metadata.project_table assert "annotations" not in dir(pyproject_metadata.project_table) pyproject-metadata-0.9.1/tests/test_rfc822.py000066400000000000000000000175651476366154300211340ustar00rootroot00000000000000# SPDX-License-Identifier: MIT from __future__ import annotations import email.message import inspect import re import textwrap import pytest import pyproject_metadata import pyproject_metadata.constants @pytest.mark.parametrize( ("items", "data"), [ pytest.param( [], "", id="empty", ), pytest.param( [ ("Foo", "Bar"), ], "Foo: Bar\n", id="simple", ), pytest.param( [ ("Foo", "Bar"), ("Foo2", "Bar2"), ], """\ Foo: Bar Foo2: Bar2 """, id="multiple", ), pytest.param( [ ("Foo", "Unicøde"), ], "Foo: Unicøde\n", id="unicode", ), pytest.param( [ ("Foo", "🕵️"), ], "Foo: 🕵️\n", id="emoji", ), pytest.param( [ ("Item", None), ], "", id="none", ), pytest.param( [ ("ItemA", "ValueA"), ("ItemB", "ValueB"), ("ItemC", "ValueC"), ], """\ ItemA: ValueA ItemB: ValueB ItemC: ValueC """, id="order 1", ), pytest.param( [ ("ItemB", "ValueB"), ("ItemC", "ValueC"), ("ItemA", "ValueA"), ], """\ ItemB: ValueB ItemC: ValueC ItemA: ValueA """, id="order 2", ), pytest.param( [ ("ItemA", "ValueA1"), ("ItemB", "ValueB"), ("ItemC", "ValueC"), ("ItemA", "ValueA2"), ], """\ ItemA: ValueA1 ItemB: ValueB ItemC: ValueC ItemA: ValueA2 """, id="multiple keys", ), pytest.param( [ ("ItemA", "ValueA"), ("ItemB", "ValueB1\nValueB2\nValueB3"), ("ItemC", "ValueC"), ], """\ ItemA: ValueA ItemB: ValueB1 ValueB2 ValueB3 ItemC: ValueC """, id="multiline", ), ], ) def test_headers( items: list[tuple[str, None | str]], data: str, monkeypatch: pytest.MonkeyPatch ) -> None: message = pyproject_metadata.RFC822Message() smart_message = pyproject_metadata._SmartMessageSetter(message) monkeypatch.setattr( pyproject_metadata.constants, "KNOWN_METADATA_FIELDS", {x.lower() for x, _ in items}, ) for name, value in items: smart_message[name] = value data = textwrap.dedent(data) + "\n" assert str(message) == data assert bytes(message) == data.encode() assert email.message_from_string(str(message)).items() == [ (a, "\n ".join(b.splitlines())) for a, b in items if b is not None ] def test_body(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( pyproject_metadata.constants, "KNOWN_METADATA_FIELDS", {"itema", "itemb", "itemc"}, ) message = pyproject_metadata.RFC822Message() message["ItemA"] = "ValueA" message["ItemB"] = "ValueB" message["ItemC"] = "ValueC" body = inspect.cleandoc(""" Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris congue semper fermentum. Nunc vitae tempor ante. Aenean aliquet posuere lacus non faucibus. In porttitor congue luctus. Vivamus eu dignissim orci. Donec egestas mi ac ipsum volutpat, vel elementum sapien consectetur. Praesent dictum finibus fringilla. Sed vel feugiat leo. Nulla a pharetra augue, at tristique metus. Aliquam fermentum elit at risus sagittis, vel pretium augue congue. Donec leo risus, faucibus vel posuere efficitur, feugiat ut leo. Aliquam vestibulum vel dolor id elementum. Ut bibendum nunc interdum neque interdum, vel tincidunt lacus blandit. Ut volutpat sollicitudin dapibus. Integer vitae lacinia ex, eget finibus nulla. Donec sit amet ante in neque pulvinar faucibus sed nec justo. Fusce hendrerit massa libero, sit amet pulvinar magna tempor quis. ø """) headers = inspect.cleandoc(""" ItemA: ValueA ItemB: ValueB ItemC: ValueC """) full = f"{headers}\n\n{body}" message.set_payload(textwrap.dedent(body)) assert str(message) == full new_message = email.message_from_string(str(message)) assert new_message.items() == message.items() assert new_message.get_payload() == message.get_payload() assert bytes(message) == full.encode("utf-8") def test_unknown_field() -> None: message = pyproject_metadata.RFC822Message() with pytest.raises( pyproject_metadata.ConfigurationError, match=re.escape("Unknown field 'Unknown'"), ): message["Unknown"] = "Value" def test_known_field() -> None: message = pyproject_metadata.RFC822Message() message["Platform"] = "Value" assert str(message) == "Platform: Value\n\n" def test_convert_optional_dependencies() -> None: metadata = pyproject_metadata.StandardMetadata.from_pyproject( { "project": { "name": "example", "version": "0.1.0", "optional-dependencies": { "test": [ 'foo; os_name == "nt" or sys_platform == "win32"', 'bar; os_name == "posix" and sys_platform == "linux"', ], }, }, } ) message = metadata.as_rfc822() requires = message.get_all("Requires-Dist") assert requires == [ 'foo; (os_name == "nt" or sys_platform == "win32") and extra == "test"', 'bar; os_name == "posix" and sys_platform == "linux" and extra == "test"', ] def test_convert_author_email() -> None: metadata = pyproject_metadata.StandardMetadata.from_pyproject( { "project": { "name": "example", "version": "0.1.0", "authors": [ { "name": "John Doe, Inc.", "email": "johndoe@example.com", }, { "name": "Kate Doe, LLC.", "email": "katedoe@example.com", }, ], }, } ) message = metadata.as_rfc822() assert message.get_all("Author-Email") == [ '"John Doe, Inc." , "Kate Doe, LLC." ' ] def test_long_version() -> None: metadata = pyproject_metadata.StandardMetadata.from_pyproject( { "project": { "name": "example", "version": "0.0.0+super.duper.long.version.string.that.is.longer.than.sixty.seven.characters", } } ) message = metadata.as_rfc822() assert ( message.get("Version") == "0.0.0+super.duper.long.version.string.that.is.longer.than.sixty.seven.characters" ) assert ( bytes(message) == inspect.cleandoc(""" Metadata-Version: 2.1 Name: example Version: 0.0.0+super.duper.long.version.string.that.is.longer.than.sixty.seven.characters """).encode("utf-8") + b"\n\n" ) assert ( str(message) == inspect.cleandoc(""" Metadata-Version: 2.1 Name: example Version: 0.0.0+super.duper.long.version.string.that.is.longer.than.sixty.seven.characters """) + "\n\n" ) pyproject-metadata-0.9.1/tests/test_standard_metadata.py000066400000000000000000001513751476366154300235640ustar00rootroot00000000000000# SPDX-License-Identifier: MIT from __future__ import annotations import contextlib import pathlib import re import shutil import sys import textwrap import warnings import packaging.specifiers import packaging.version import pytest if sys.version_info < (3, 11): import tomli as tomllib else: import tomllib import pyproject_metadata import pyproject_metadata.constants DIR = pathlib.Path(__file__).parent.resolve() try: import exceptiongroup except ImportError: exceptiongroup = None # type: ignore[assignment] @pytest.fixture(params=pyproject_metadata.constants.KNOWN_METADATA_VERSIONS) def metadata_version(request: pytest.FixtureRequest) -> str: return request.param # type: ignore[no-any-return] @pytest.fixture(params=["one_error", "all_errors", "exceptiongroup"]) def all_errors(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> bool: param: str = request.param if param == "exceptiongroup": if exceptiongroup is None: pytest.skip("exceptiongroup is not installed") monkeypatch.setattr( pyproject_metadata.errors, "ExceptionGroup", exceptiongroup.ExceptionGroup ) return param != "one_error" @pytest.mark.parametrize( ("data", "error"), [ pytest.param( "", 'Section "project" missing in pyproject.toml', id="Missing project section", ), pytest.param( """ [project] name = true version = "0.1.0" """, 'Field "project.name" has an invalid type, expecting a string (got bool)', id="Invalid name type", ), pytest.param( """ [project] name = "test" version = "0.1.0" not-real-key = true """, "Extra keys present in \"project\": 'not-real-key'", id="Invalid project key", ), pytest.param( """ [project] name = "test" version = "0.1.0" dynamic = [ "name", ] """, "Unsupported field 'name' in \"project.dynamic\"", id="Unsupported field in project.dynamic", ), pytest.param( """ [project] name = "test" version = "0.1.0" dynamic = [ 3, ] """, 'Field "project.dynamic" contains item with invalid type, expecting a string (got int)', id="Unsupported type in project.dynamic", ), pytest.param( """ [project] name = "test" version = true """, 'Field "project.version" has an invalid type, expecting a string (got bool)', id="Invalid version type", ), pytest.param( """ [project] name = "test" """, 'Field "project.version" missing and \'version\' not specified in "project.dynamic"', id="Missing version", ), pytest.param( """ [project] name = "test" version = "0.1.0-extra" """, "Invalid \"project.version\" value, expecting a valid PEP 440 version (got '0.1.0-extra')", id="Invalid version value", ), pytest.param( """ [project] name = "test" version = "0.1.0" license = true """, 'Field "project.license" has an invalid type, expecting a string or table of strings (got bool)', id="License invalid type", ), pytest.param( """ [project] name = "test" version = "0.1.0" license = {} """, 'Invalid "project.license" contents, expecting a string or one key "file" or "text" (got {})', id="Missing license keys", ), pytest.param( """ [project] name = "test" version = "0.1.0" license = { file = "...", text = "..." } """, ( 'Invalid "project.license" contents, expecting a string or one key "file" or "text"' " (got {'file': '...', 'text': '...'})" ), id="Both keys for license", ), pytest.param( """ [project] name = "test" version = "0.1.0" license = { made-up = ":(" } """, 'Unexpected field "project.license.made-up"', id="Got made-up license field", ), pytest.param( """ [project] name = "test" version = "0.1.0" license = { file = true } """, 'Field "project.license.file" has an invalid type, expecting a string (got bool)', id="Invalid type for license.file", ), pytest.param( """ [project] name = "test" version = "0.1.0" license = { text = true } """, 'Field "project.license.text" has an invalid type, expecting a string (got bool)', id="Invalid type for license.text", ), pytest.param( """ [project] name = "test" version = "0.1.0" license = { file = "this-file-does-not-exist" } """, "License file not found ('this-file-does-not-exist')", id="License file not present", ), pytest.param( """ [project] name = "test" version = "0.1.0" readme = true """, ( 'Field "project.readme" has an invalid type, expecting either ' "a string or table of strings (got bool)" ), id="Invalid readme type", ), pytest.param( """ [project] name = "test" version = "0.1.0" readme = {} """, 'Invalid "project.readme" contents, expecting either "file" or "text" (got {})', id="Empty readme table", ), pytest.param( """ [project] name = 'test' version = "0.1.0" readme = "README.jpg" """, "Could not infer content type for readme file 'README.jpg'", id="Unsupported filename in readme", ), pytest.param( """ [project] name = "test" version = "0.1.0" readme = { file = "...", text = "..." } """, ( 'Invalid "project.readme" contents, expecting either "file" or "text"' " (got {'file': '...', 'text': '...'})" ), id="Both readme fields", ), pytest.param( """ [project] name = "test" version = "0.1.0" readme = { made-up = ":(" } """, 'Unexpected field "project.readme.made-up"', id="Unexpected field in readme", ), pytest.param( """ [project] name = "test" version = "0.1.0" readme = { file = true } """, 'Field "project.readme.file" has an invalid type, expecting a string (got bool)', id="Invalid type for readme.file", ), pytest.param( """ [project] name = "test" version = "0.1.0" readme = { text = true } """, 'Field "project.readme.text" has an invalid type, expecting a string (got bool)', id="Invalid type for readme.text", ), pytest.param( """ [project] name = "test" version = "0.1.0" readme = { file = "this-file-does-not-exist", content-type = "..." } """, "Readme file not found ('this-file-does-not-exist')", id="Readme file not present", ), pytest.param( """ [project] name = "test" version = "0.1.0" readme = { file = "README.md" } """, 'Field "project.readme.content-type" missing', id="Missing content-type for readme", ), pytest.param( """ [project] name = "test" version = "0.1.0" readme = { file = 'README.md', content-type = true } """, 'Field "project.readme.content-type" has an invalid type, expecting a string (got bool)', id="Wrong content-type type for readme", ), pytest.param( """ [project] name = "test" version = "0.1.0" readme = { text = "..." } """, 'Field "project.readme.content-type" missing', id="Missing content-type for readme", ), pytest.param( """ [project] name = "test" version = "0.1.0" description = true """, 'Field "project.description" has an invalid type, expecting a string (got bool)', id="Invalid description type", ), pytest.param( """ [project] name = "test" version = "0.1.0" dependencies = "some string!" """, 'Field "project.dependencies" has an invalid type, expecting a list of strings (got str)', id="Invalid dependencies type", ), pytest.param( """ [project] name = "test" version = "0.1.0" dependencies = [ 99, ] """, 'Field "project.dependencies" contains item with invalid type, expecting a string (got int)', id="Invalid dependencies item type", ), pytest.param( """ [project] name = "test" version = "0.1.0" dependencies = [ "definitely not a valid PEP 508 requirement!", ] """, ( 'Field "project.dependencies" contains an invalid PEP 508 requirement ' "string 'definitely not a valid PEP 508 requirement!' " ), id="Invalid dependencies item", ), pytest.param( """ [project] name = "test" version = "0.1.0" optional-dependencies = true """, ( 'Field "project.optional-dependencies" has an invalid type, ' "expecting a table of PEP 508 requirement strings (got bool)" ), id="Invalid optional-dependencies type", ), pytest.param( """ [project] name = "test" version = "0.1.0" [project.optional-dependencies] test = "some string!" """, ( 'Field "project.optional-dependencies.test" has an invalid type, ' "expecting a table of PEP 508 requirement strings (got str)" ), id="Invalid optional-dependencies not list", ), pytest.param( """ [project] name = "test" version = "0.1.0" [project.optional-dependencies] test = [ true, ] """, ( 'Field "project.optional-dependencies.test" has an invalid type, ' "expecting a PEP 508 requirement string (got bool)" ), id="Invalid optional-dependencies item type", ), pytest.param( """ [project] name = "test" version = "0.1.0" [project.optional-dependencies] test = [ "definitely not a valid PEP 508 requirement!", ] """, ( 'Field "project.optional-dependencies.test" contains an invalid ' "PEP 508 requirement string 'definitely not a valid PEP 508 requirement!' " ), id="Invalid optional-dependencies item", ), pytest.param( """ [project] name = "test" version = "0.1.0" requires-python = true """, 'Field "project.requires-python" has an invalid type, expecting a string (got bool)', id="Invalid requires-python type", ), pytest.param( """ [project] name = "test" version = "0.1.0" requires-python = "3.8" """, "Invalid \"project.requires-python\" value, expecting a valid specifier set (got '3.8')", id="Invalid requires-python value", ), pytest.param( """ [project] name = "test" version = "0.1.0" keywords = "some string!" """, 'Field "project.keywords" has an invalid type, expecting a list of strings (got str)', id="Invalid keywords type", ), pytest.param( """ [project] name = "test" version = "0.1.0" keywords = [3] """, 'Field "project.keywords" contains item with invalid type, expecting a string (got int)', id="Invalid keyword type", ), pytest.param( """ [project] name = "test" version = "0.1.0" keywords = [ true, ] """, 'Field "project.keywords" contains item with invalid type, expecting a string (got bool)', id="Invalid keywords item type", ), pytest.param( """ [project] name = "test" version = "0.1.0" authors = {} """, ( 'Field "project.authors" has an invalid type, expecting a list of ' 'tables containing the "name" and/or "email" keys (got dict)' ), id="Invalid authors type", ), pytest.param( """ [project] name = "test" version = "0.1.0" authors = [ true, ] """, ( 'Field "project.authors" has an invalid type, expecting a list of ' 'tables containing the "name" and/or "email" keys (got list with bool)' ), id="Invalid authors item type", ), pytest.param( """ [project] name = "test" version = "0.1.0" maintainers = {} """, ( 'Field "project.maintainers" has an invalid type, expecting a list of ' 'tables containing the "name" and/or "email" keys (got dict)' ), id="Invalid maintainers type", ), pytest.param( """ [project] name = "test" version = "0.1.0" maintainers = [ 10 ] """, ( 'Field "project.maintainers" has an invalid type, expecting a list of ' 'tables containing the "name" and/or "email" keys (got list with int)' ), id="Invalid maintainers item type", ), pytest.param( """ [project] name = "test" version = "0.1.0" maintainers = [ {"name" = 12} ] """, ( 'Field "project.maintainers" has an invalid type, expecting a list of ' 'tables containing the "name" and/or "email" keys (got list with dict with int)' ), id="Invalid maintainers nested type", ), pytest.param( """ [project] name = "test" version = "0.1.0" maintainers = [ {"name" = "me", "other" = "you"} ] """, ( 'Field "project.maintainers" has an invalid type, expecting a list of ' 'tables containing the "name" and/or "email" keys (got list with dict with extra keys "other")' ), id="Invalid maintainers nested type", ), pytest.param( """ [project] name = "test" version = "0.1.0" classifiers = "some string!" """, 'Field "project.classifiers" has an invalid type, expecting a list of strings (got str)', id="Invalid classifiers type", ), pytest.param( """ [project] name = "test" version = "0.1.0" classifiers = [ true, ] """, 'Field "project.classifiers" contains item with invalid type, expecting a string (got bool)', id="Invalid classifiers item type", ), pytest.param( """ [project] name = "test" version = "0.1.0" [project.urls] homepage = true """, 'Field "project.urls.homepage" has an invalid type, expecting a string (got bool)', id="Invalid urls homepage type", ), pytest.param( """ [project] name = "test" version = "0.1.0" [project.urls] Documentation = true """, 'Field "project.urls.Documentation" has an invalid type, expecting a string (got bool)', id="Invalid urls documentation type", ), pytest.param( """ [project] name = "test" version = "0.1.0" [project.urls] repository = true """, 'Field "project.urls.repository" has an invalid type, expecting a string (got bool)', id="Invalid urls repository type", ), pytest.param( """ [project] name = "test" version = "0.1.0" [project.urls] "I am really really too long for this place" = "url" """, "\"project.urls\" names cannot be more than 32 characters long (got 'I am really really too long for this place')", id="URL name too long", ), pytest.param( """ [project] name = "test" version = "0.1.0" [project.urls] changelog = true """, 'Field "project.urls.changelog" has an invalid type, expecting a string (got bool)', id="Invalid urls changelog type", ), pytest.param( """ [project] name = "test" version = "0.1.0" scripts = [] """, 'Field "project.scripts" has an invalid type, expecting a table of strings (got list)', id="Invalid scripts type", ), pytest.param( """ [project] name = "test" version = "0.1.0" gui-scripts = [] """, 'Field "project.gui-scripts" has an invalid type, expecting a table of strings (got list)', id="Invalid gui-scripts type", ), pytest.param( """ [project] name = "test" version = "0.1.0" entry-points = [] """, ( 'Field "project.entry-points" has an invalid type, ' "expecting a table of entrypoint sections (got list)" ), id="Invalid entry-points type", ), pytest.param( """ [project] name = "test" version = "0.1.0" entry-points = { section = "something" } """, ( 'Field "project.entry-points.section" has an invalid type, ' "expecting a table of entrypoints (got str)" ), id="Invalid entry-points section type", ), pytest.param( """ [project] name = "test" version = "0.1.0" [project.entry-points.section] entrypoint = [] """, 'Field "project.entry-points.section.entrypoint" has an invalid type, expecting a string (got list)', id="Invalid entry-points entrypoint type", ), pytest.param( """ [project] name = ".test" version = "0.1.0" """, ( "Invalid project name '.test'. A valid name consists only of ASCII letters and " "numbers, period, underscore and hyphen. It must start and end with a letter or number" ), id="Invalid project name", ), pytest.param( """ [project] name = "test" version = "0.1.0" [project.entry-points.bad-name] """, ( 'Field "project.entry-points" has an invalid value, expecting a name containing only ' "alphanumeric, underscore, or dot characters (got 'bad-name')" ), id="Invalid entry-points name", ), # both license files and classic license are not allowed pytest.param( """ [project] name = "test" version = "0.1.0" license-files = [] license.text = 'stuff' """, '"project.license-files" must not be used when "project.license" is not a SPDX license expression', id="Both license files and classic license", ), pytest.param( """ [project] name = "test" version = "0.1.0" license-files = ['../LICENSE'] """, "'../LICENSE' is an invalid \"project.license-files\" glob: the pattern must match files within the project directory", id="Parent license-files glob", ), pytest.param( """ [project] name = "test" version = "0.1.0" license-files = [12] """, 'Field "project.license-files" contains item with invalid type, expecting a string (got int)', id="Parent license-files invalid type", ), pytest.param( """ [project] name = "test" version = "0.1.0" license-files = ['this', 12] """, 'Field "project.license-files" contains item with invalid type, expecting a string (got int)', id="Parent license-files invalid type", ), pytest.param( """ [project] name = "test" version = "0.1.0" license-files = ['/LICENSE'] """, "'/LICENSE' is an invalid \"project.license-files\" glob: the pattern must match files within the project directory", id="Absolute license-files glob", ), pytest.param( """ [project] name = "test" version = "0.1.0" license = 'MIT' classifiers = ['License :: OSI Approved :: MIT License'] """, "Setting \"project.license\" to an SPDX license expression is not compatible with 'License ::' classifiers", id="SPDX license and License trove classifiers", ), ], ) def test_load( data: str, error: str, monkeypatch: pytest.MonkeyPatch, all_errors: bool ) -> None: monkeypatch.chdir(DIR / "packages/full-metadata") if not all_errors: with pytest.raises( pyproject_metadata.ConfigurationError, match=re.escape(error) ): pyproject_metadata.StandardMetadata.from_pyproject( tomllib.loads(textwrap.dedent(data)), allow_extra_keys=False, ) else: with warnings.catch_warnings(): warnings.simplefilter( action="ignore", category=pyproject_metadata.errors.ConfigurationWarning ) with pytest.raises(pyproject_metadata.errors.ExceptionGroup) as execinfo: pyproject_metadata.StandardMetadata.from_pyproject( tomllib.loads(textwrap.dedent(data)), allow_extra_keys=False, all_errors=True, ) exceptions = execinfo.value.exceptions args = [e.args[0] for e in exceptions] assert len(args) == 1 assert error in args[0] assert "Failed to parse pyproject.toml" in repr(execinfo.value) @pytest.mark.parametrize( ("data", "errors"), [ pytest.param( "[project]", [ 'Field "project.name" missing', 'Field "project.version" missing and \'version\' not specified in "project.dynamic"', ], id="Missing project name", ), pytest.param( """ [project] name = true version = "0.1.0" dynamic = [ "name", ] """, [ "Unsupported field 'name' in \"project.dynamic\"", 'Field "project.name" has an invalid type, expecting a string (got bool)', ], id="Unsupported field in project.dynamic", ), pytest.param( """ [project] name = true version = "0.1.0" dynamic = [ 3, ] """, [ 'Field "project.dynamic" contains item with invalid type, expecting a string (got int)', 'Field "project.name" has an invalid type, expecting a string (got bool)', ], id="Unsupported type in project.dynamic", ), pytest.param( """ [project] name = 'test' version = "0.1.0" readme = "README.jpg" license-files = [12] """, [ 'Field "project.license-files" contains item with invalid type, expecting a string (got int)', "Could not infer content type for readme file 'README.jpg'", ], id="Unsupported filename in readme", ), pytest.param( """ [project] name = 'test' version = "0.1.0" readme = "README.jpg" license-files = [12] entry-points.bad-name = {} other-entry = {} not-valid = true """, [ "Extra keys present in \"project\": 'not-valid', 'other-entry'", 'Field "project.license-files" contains item with invalid type, expecting a string (got int)', "Could not infer content type for readme file 'README.jpg'", "Field \"project.entry-points\" has an invalid value, expecting a name containing only alphanumeric, underscore, or dot characters (got 'bad-name')", ], id="Four errors including extra keys", ), ], ) def test_load_multierror( data: str, errors: list[str], monkeypatch: pytest.MonkeyPatch, all_errors: bool ) -> None: monkeypatch.chdir(DIR / "packages/full-metadata") if not all_errors: with pytest.raises( pyproject_metadata.ConfigurationError, match=re.escape(errors[0]) ): pyproject_metadata.StandardMetadata.from_pyproject( tomllib.loads(textwrap.dedent(data)), allow_extra_keys=False, ) else: with warnings.catch_warnings(): warnings.simplefilter( action="ignore", category=pyproject_metadata.errors.ConfigurationWarning ) with pytest.raises(pyproject_metadata.errors.ExceptionGroup) as execinfo: pyproject_metadata.StandardMetadata.from_pyproject( tomllib.loads(textwrap.dedent(data)), allow_extra_keys=False, all_errors=True, ) exceptions = execinfo.value.exceptions args = [e.args[0] for e in exceptions] assert len(args) == len(errors) assert args == errors assert "Failed to parse pyproject.toml" in repr(execinfo.value) @pytest.mark.parametrize( ("data", "error", "metadata_version"), [ pytest.param( """ [project] name = "test" version = "0.1.0" license = 'MIT' """, 'Setting "project.license" to an SPDX license expression is supported only when emitting metadata version >= 2.4', "2.3", id="SPDX with metadata_version 2.3", ), pytest.param( """ [project] name = "test" version = "0.1.0" license-files = ['README.md'] """, '"project.license-files" is supported only when emitting metadata version >= 2.4', "2.3", id="license-files with metadata_version 2.3", ), ], ) def test_load_with_metadata_version( data: str, error: str, metadata_version: str, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(DIR / "packages/full-metadata") with pytest.raises(pyproject_metadata.ConfigurationError, match=re.escape(error)): pyproject_metadata.StandardMetadata.from_pyproject( tomllib.loads(textwrap.dedent(data)), metadata_version=metadata_version ) @pytest.mark.parametrize( ("data", "error", "metadata_version"), [ pytest.param( """ [project] name = "test" version = "0.1.0" license.text = 'MIT' """, 'Set "project.license" to an SPDX license expression for metadata >= 2.4', "2.4", id="Classic license with metadata 2.4", ), pytest.param( """ [project] name = "test" version = "0.1.0" classifiers = ['License :: OSI Approved :: MIT License'] """, "'License ::' classifiers are deprecated for metadata >= 2.4, use a SPDX license expression for \"project.license\" instead", "2.4", id="License trove classifiers with metadata 2.4", ), ], ) def test_load_with_metadata_version_warnings( data: str, error: str, metadata_version: str, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(DIR / "packages/full-metadata") with pytest.warns( pyproject_metadata.errors.ConfigurationWarning, match=re.escape(error) ): pyproject_metadata.StandardMetadata.from_pyproject( tomllib.loads(textwrap.dedent(data)), metadata_version=metadata_version ) @pytest.mark.parametrize("after_rfc", [False, True]) def test_value(after_rfc: bool, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(DIR / "packages/full-metadata") with open("pyproject.toml", "rb") as f: metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f)) if after_rfc: metadata.as_rfc822() assert metadata.dynamic == [] assert metadata.name == "full_metadata" assert metadata.canonical_name == "full-metadata" assert metadata.version == packaging.version.Version("3.2.1") assert metadata.requires_python == packaging.specifiers.Specifier(">=3.8") assert isinstance(metadata.license, pyproject_metadata.License) assert metadata.license.file is None assert metadata.license.text == "some license text" assert isinstance(metadata.readme, pyproject_metadata.Readme) assert metadata.readme.file == pathlib.Path("README.md") assert metadata.readme.text == pathlib.Path("README.md").read_text(encoding="utf-8") assert metadata.readme.content_type == "text/markdown" assert metadata.description == "A package with all the metadata :)" assert metadata.authors == [ ("Unknown", "example@example.com"), ("Example!", None), ] assert metadata.maintainers == [ ("Other Example", "other@example.com"), ] assert metadata.keywords == ["trampolim", "is", "interesting"] assert metadata.classifiers == [ "Development Status :: 4 - Beta", "Programming Language :: Python", ] assert metadata.urls == { "changelog": "github.com/some/repo/blob/master/CHANGELOG.rst", "documentation": "readthedocs.org", "homepage": "example.com", "repository": "github.com/some/repo", } assert metadata.entrypoints == { "custom": { "full-metadata": "full_metadata:main_custom", }, } assert metadata.scripts == { "full-metadata": "full_metadata:main_cli", } assert metadata.gui_scripts == { "full-metadata-gui": "full_metadata:main_gui", } assert list(map(str, metadata.dependencies)) == [ "dependency1", "dependency2>1.0.0", "dependency3[extra]", 'dependency4; os_name != "nt"', 'dependency5[other-extra]>1.0; os_name == "nt"', ] assert list(metadata.optional_dependencies.keys()) == ["test"] assert list(map(str, metadata.optional_dependencies["test"])) == [ "test_dependency", "test_dependency[test_extra]", 'test_dependency[test_extra2]>3.0; os_name == "nt"', ] def test_read_license(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(DIR / "packages/full-metadata2") with open("pyproject.toml", "rb") as f: metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f)) assert isinstance(metadata.license, pyproject_metadata.License) assert metadata.license.file == pathlib.Path("LICENSE") assert metadata.license.text == "Some license! 👋\n" @pytest.mark.parametrize( ("package", "content_type"), [ ("full-metadata", "text/markdown"), ("full-metadata2", "text/x-rst"), ], ) def test_readme_content_type( package: str, content_type: str, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(DIR / "packages" / package) with open("pyproject.toml", "rb") as f: metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f)) assert isinstance(metadata.readme, pyproject_metadata.Readme) assert metadata.readme.content_type == content_type def test_readme_content_type_unknown(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(DIR / "packages/unknown-readme-type") with pytest.raises( pyproject_metadata.ConfigurationError, match=re.escape( "Could not infer content type for readme file 'README.just-made-this-up-now'" ), ), open("pyproject.toml", "rb") as f: pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f)) def test_as_json(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(DIR / "packages/full-metadata") with open("pyproject.toml", "rb") as f: metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f)) core_metadata = metadata.as_json() assert core_metadata == { "author": "Example!", "author_email": "Unknown ", "classifier": [ "Development Status :: 4 - Beta", "Programming Language :: Python", ], "description": "some readme 👋\n", "description_content_type": "text/markdown", "keywords": ["trampolim", "is", "interesting"], "license": "some license text", "maintainer_email": "Other Example ", "metadata_version": "2.1", "name": "full_metadata", "project_url": [ "homepage, example.com", "documentation, readthedocs.org", "repository, github.com/some/repo", "changelog, github.com/some/repo/blob/master/CHANGELOG.rst", ], "provides_extra": ["test"], "requires_dist": [ "dependency1", "dependency2>1.0.0", "dependency3[extra]", 'dependency4; os_name != "nt"', 'dependency5[other-extra]>1.0; os_name == "nt"', 'test_dependency; extra == "test"', 'test_dependency[test_extra]; extra == "test"', 'test_dependency[test_extra2]>3.0; os_name == "nt" and extra == "test"', ], "requires_python": ">=3.8", "summary": "A package with all the metadata :)", "version": "3.2.1", } def test_as_rfc822(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(DIR / "packages/full-metadata") with open("pyproject.toml", "rb") as f: metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f)) core_metadata = metadata.as_rfc822() assert core_metadata.items() == [ ("Metadata-Version", "2.1"), ("Name", "full_metadata"), ("Version", "3.2.1"), ("Summary", "A package with all the metadata :)"), ("Keywords", "trampolim,is,interesting"), ("Author", "Example!"), ("Author-Email", "Unknown "), ("Maintainer-Email", "Other Example "), ("License", "some license text"), ("Classifier", "Development Status :: 4 - Beta"), ("Classifier", "Programming Language :: Python"), ("Project-URL", "homepage, example.com"), ("Project-URL", "documentation, readthedocs.org"), ("Project-URL", "repository, github.com/some/repo"), ("Project-URL", "changelog, github.com/some/repo/blob/master/CHANGELOG.rst"), ("Requires-Python", ">=3.8"), ("Requires-Dist", "dependency1"), ("Requires-Dist", "dependency2>1.0.0"), ("Requires-Dist", "dependency3[extra]"), ("Requires-Dist", 'dependency4; os_name != "nt"'), ("Requires-Dist", 'dependency5[other-extra]>1.0; os_name == "nt"'), ("Provides-Extra", "test"), ("Requires-Dist", 'test_dependency; extra == "test"'), ("Requires-Dist", 'test_dependency[test_extra]; extra == "test"'), ( "Requires-Dist", 'test_dependency[test_extra2]>3.0; os_name == "nt" and extra == "test"', ), ("Description-Content-Type", "text/markdown"), ] assert core_metadata.get_payload() == "some readme 👋\n" def test_as_json_spdx(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(DIR / "packages/spdx") with open("pyproject.toml", "rb") as f: metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f)) core_metadata = metadata.as_json() assert core_metadata == { "license_expression": "MIT OR GPL-2.0-or-later OR (FSFUL AND BSD-2-Clause)", "license_file": [ "AUTHORS.txt", "LICENSE.md", "LICENSE.txt", "licenses/LICENSE.MIT", ], "metadata_version": "2.4", "name": "example", "version": "1.2.3", } def test_as_rfc822_spdx(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(DIR / "packages/spdx") with open("pyproject.toml", "rb") as f: metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f)) core_metadata = metadata.as_rfc822() assert core_metadata.items() == [ ("Metadata-Version", "2.4"), ("Name", "example"), ("Version", "1.2.3"), ("License-Expression", "MIT OR GPL-2.0-or-later OR (FSFUL AND BSD-2-Clause)"), ("License-File", "AUTHORS.txt"), ("License-File", "LICENSE.md"), ("License-File", "LICENSE.txt"), ("License-File", "licenses/LICENSE.MIT"), ] assert core_metadata.get_payload() is None def test_as_rfc822_spdx_empty_glob( monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, all_errors: bool ) -> None: shutil.copytree(DIR / "packages/spdx", tmp_path / "spdx") monkeypatch.chdir(tmp_path / "spdx") pathlib.Path("AUTHORS.txt").unlink() msg = "Every pattern in \"project.license-files\" must match at least one file: 'AUTHORS*' did not match any" with open("pyproject.toml", "rb") as f: if all_errors: with pytest.raises( pyproject_metadata.errors.ExceptionGroup, ) as execinfo: pyproject_metadata.StandardMetadata.from_pyproject( tomllib.load(f), all_errors=all_errors ) assert "Failed to parse pyproject.toml" in str(execinfo.value) assert [msg] == [str(e) for e in execinfo.value.exceptions] else: with pytest.raises( pyproject_metadata.ConfigurationError, match=re.escape(msg), ): pyproject_metadata.StandardMetadata.from_pyproject( tomllib.load(f), all_errors=all_errors ) def test_license_file_24( metadata_version: str, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(DIR / "packages/fulltext_license") pre_spdx = ( metadata_version in pyproject_metadata.constants.PRE_SPDX_METADATA_VERSIONS ) with ( contextlib.nullcontext() if pre_spdx else pytest.warns(pyproject_metadata.errors.ConfigurationWarning) ): metadata = pyproject_metadata.StandardMetadata.from_pyproject( { "project": { "name": "fulltext_license", "version": "0.1.0", "license": {"file": "LICENSE.txt"}, }, }, metadata_version=metadata_version, ) message = str(metadata.as_rfc822()) if metadata_version in pyproject_metadata.constants.PRE_SPDX_METADATA_VERSIONS: assert "License-File: LICENSE.txt" not in message else: assert "License-File: LICENSE.txt" in message bmessage = bytes(metadata.as_rfc822()) if metadata_version in pyproject_metadata.constants.PRE_SPDX_METADATA_VERSIONS: assert b"License-File: LICENSE.txt" not in bmessage else: assert b"License-File: LICENSE.txt" in bmessage def test_license_file_broken(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(DIR / "packages/broken_license") metadata = pyproject_metadata.StandardMetadata.from_pyproject( { "project": { "name": "broken_license", "version": "0.1.0", "license": {"file": "LICENSE"}, }, }, ) message = str(metadata.as_rfc822()) assert "License-File: LICENSE" not in message bmessage = bytes(metadata.as_rfc822()) assert b"License-File: LICENSE" not in bmessage def test_as_rfc822_dynamic(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(DIR / "packages/dynamic-description") with open("pyproject.toml", "rb") as f: metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f)) metadata.dynamic_metadata = ["description"] core_metadata = metadata.as_rfc822() assert core_metadata.items() == [ ("Metadata-Version", "2.2"), ("Name", "dynamic-description"), ("Version", "1.0.0"), ("Dynamic", "description"), ] def test_as_rfc822_set_metadata(metadata_version: str) -> None: metadata = pyproject_metadata.StandardMetadata.from_pyproject( { "project": { "name": "hi", "version": "1.2", "optional-dependencies": { "under_score": ["some_package"], "da-sh": ["some-package"], "do.t": ["some.package"], "empty": [], }, } }, metadata_version=metadata_version, ) assert metadata.metadata_version == metadata_version rfc822 = bytes(metadata.as_rfc822()).decode("utf-8") assert f"Metadata-Version: {metadata_version}" in rfc822 assert "Provides-Extra: under-score" in rfc822 assert "Provides-Extra: da-sh" in rfc822 assert "Provides-Extra: do-t" in rfc822 assert "Provides-Extra: empty" in rfc822 assert 'Requires-Dist: some_package; extra == "under-score"' in rfc822 assert 'Requires-Dist: some-package; extra == "da-sh"' in rfc822 assert 'Requires-Dist: some.package; extra == "do-t"' in rfc822 def test_as_json_set_metadata() -> None: metadata = pyproject_metadata.StandardMetadata.from_pyproject( { "project": { "name": "hi", "version": "1.2", "optional-dependencies": { "under_score": ["some_package"], "da-sh": ["some-package"], "do.t": ["some.package"], "empty": [], }, } }, metadata_version="2.1", ) assert metadata.metadata_version == "2.1" json = metadata.as_json() assert json == { "metadata_version": "2.1", "name": "hi", "provides_extra": ["under-score", "da-sh", "do-t", "empty"], "requires_dist": [ 'some_package; extra == "under-score"', 'some-package; extra == "da-sh"', 'some.package; extra == "do-t"', ], "version": "1.2", } def test_as_rfc822_set_metadata_invalid() -> None: with pytest.raises( pyproject_metadata.ConfigurationError, match="The metadata_version must be one of", ) as err: pyproject_metadata.StandardMetadata.from_pyproject( { "project": { "name": "hi", "version": "1.2", }, }, metadata_version="2.0", ) assert "2.1" in str(err.value) assert "2.2" in str(err.value) assert "2.3" in str(err.value) def test_as_rfc822_invalid_dynamic() -> None: metadata = pyproject_metadata.StandardMetadata( name="something", version=packaging.version.Version("1.0.0"), dynamic_metadata=["name"], ) with pytest.raises( pyproject_metadata.ConfigurationError, match="Metadata field 'name' cannot be declared dynamic", ): metadata.as_rfc822() metadata.dynamic_metadata = ["Version"] with pytest.raises( pyproject_metadata.ConfigurationError, match="Metadata field 'Version' cannot be declared dynamic", ): metadata.as_rfc822() metadata.dynamic_metadata = ["unknown"] with pytest.raises( pyproject_metadata.ConfigurationError, match="Unknown metadata field 'unknown' cannot be declared dynamic", ): metadata.as_rfc822() def test_as_rfc822_mapped_dynamic() -> None: metadata = pyproject_metadata.StandardMetadata( name="something", version=packaging.version.Version("1.0.0"), dynamic_metadata=list(pyproject_metadata.field_to_metadata("description")), ) assert ( str(metadata.as_rfc822()) == "Metadata-Version: 2.2\nName: something\nVersion: 1.0.0\nDynamic: Summary\n\n" ) def test_as_rfc822_missing_version() -> None: metadata = pyproject_metadata.StandardMetadata(name="something") with pytest.raises( pyproject_metadata.ConfigurationError, match='Field "project.version" missing' ): metadata.as_rfc822() def test_statically_defined_dynamic_field() -> None: with pytest.raises( pyproject_metadata.ConfigurationError, match='Field "project.version" declared as dynamic in "project.dynamic" but is defined', ): pyproject_metadata.StandardMetadata.from_pyproject( { "project": { "name": "example", "version": "1.2.3", "dynamic": [ "version", ], }, } ) @pytest.mark.parametrize( "value", [ "<3.10", ">3.7,<3.11", ">3.7,<3.11,!=3.8.4", "~=3.10,!=3.10.3", ], ) def test_requires_python(value: str) -> None: pyproject_metadata.StandardMetadata.from_pyproject( { "project": { "name": "example", "version": "0.1.0", "requires-python": value, }, } ) def test_version_dynamic() -> None: metadata = pyproject_metadata.StandardMetadata.from_pyproject( { "project": { "name": "example", "dynamic": [ "version", ], }, } ) metadata.version = packaging.version.Version("1.2.3") def test_modify_dynamic() -> None: metadata = pyproject_metadata.StandardMetadata.from_pyproject( { "project": { "name": "example", "version": "1.2.3", "dynamic": [ "requires-python", ], }, } ) metadata.requires_python = packaging.specifiers.SpecifierSet(">=3.12") metadata.version = packaging.version.Version("1.2.3") def test_missing_keys_warns() -> None: with pytest.warns( pyproject_metadata.errors.ConfigurationWarning, match=re.escape("Extra keys present in \"project\": 'not-real-key'"), ): pyproject_metadata.StandardMetadata.from_pyproject( { "project": { "name": "example", "version": "1.2.3", "not-real-key": True, }, } ) def test_missing_keys_okay() -> None: pyproject_metadata.StandardMetadata.from_pyproject( { "project": {"name": "example", "version": "1.2.3", "not-real-key": True}, }, allow_extra_keys=True, ) def test_extra_top_level() -> None: assert not pyproject_metadata.extras_top_level( {"project": {}, "dependency-groups": {}} ) assert {"also-not-real", "not-real"} == pyproject_metadata.extras_top_level( { "not-real": {}, "also-not-real": {}, "project": {}, "build-system": {}, } ) def test_extra_build_system() -> None: assert not pyproject_metadata.extras_build_system( { "build-system": { "build-backend": "one", "requires": ["two"], "backend-path": "local", }, } ) assert {"also-not-real", "not-real"} == pyproject_metadata.extras_build_system( { "build-system": { "not-real": {}, "also-not-real": {}, } } ) def test_multiline_description_warns() -> None: with pytest.warns( pyproject_metadata.errors.ConfigurationWarning, match=re.escape( 'The one-line summary "project.description" should not contain more than one line. Readers might merge or truncate newlines.' ), ): pyproject_metadata.StandardMetadata.from_pyproject( { "project": { "name": "example", "version": "1.2.3", "description": "this\nis multiline", }, } )