pax_global_header00006660000000000000000000000064146101102770014511gustar00rootroot0000000000000052 comment=f57af2554dc228f872b7d63784c2225751ab70b1 pyproject-metadata-0.8.0/000077500000000000000000000000001461011027700153135ustar00rootroot00000000000000pyproject-metadata-0.8.0/.github/000077500000000000000000000000001461011027700166535ustar00rootroot00000000000000pyproject-metadata-0.8.0/.github/workflows/000077500000000000000000000000001461011027700207105ustar00rootroot00000000000000pyproject-metadata-0.8.0/.github/workflows/checks.yml000066400000000000000000000006061461011027700226750ustar00rootroot00000000000000name: checks on: push: pull_request: branches: - main jobs: mypy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Setup Python uses: actions/setup-python@v2 with: python-version: 3.7 - name: Install nox run: python -m pip install nox - name: Run check for type run: nox -s mypy pyproject-metadata-0.8.0/.github/workflows/release.yml000066400000000000000000000015271461011027700230600ustar00rootroot00000000000000name: Package on: workflow_dispatch: 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 steps: - name: Download packages built by build-and-inspect-python-package uses: actions/download-artifact@v4 with: name: Packages path: dist - name: Upload package to PyPI uses: pypa/gh-action-pypi-publish@release/v1 pyproject-metadata-0.8.0/.github/workflows/tests.yml000066400000000000000000000037731461011027700226070ustar00rootroot00000000000000name: tests on: push: branches: - main pull_request: branches: - main jobs: pytest: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: - 'windows-latest' - 'ubuntu-latest' python: - '3.7' - '3.8' - '3.9' - '3.10' - '3.11' - '3.12' include: - os: macos-latest python: '3.7' - os: macos-latest python: '3.12' steps: - name: Checkout uses: actions/checkout@v3 - name: Set up target Python uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} - name: Pick environment to run run: | import platform; import os; import sys; import codecs base = '.'.join(map(str, sys.version_info[:2])) env = f'BASE={base}\n' print(f'Picked:\n{env}for{sys.version}') with codecs.open(os.environ['GITHUB_ENV'], 'a', 'utf-8') as file_handler: file_handler.write(env) shell: python - name: Set up Python for nox uses: actions/setup-python@v4 with: python-version: 3.9 - name: Install nox run: | python -m pip install nox nox --version - name: Run tests run: nox -s test-${{ env.BASE }} - name: Send coverage report uses: codecov/codecov-action@v1 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.8.0/.gitignore000066400000000000000000000060061461011027700173050ustar00rootroot00000000000000# 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.8.0/.pre-commit-config.yaml000066400000000000000000000010531461011027700215730ustar00rootroot00000000000000ci: autofix_prs: false autoupdate_commit_msg: 'pre-commit: bump repositories' repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.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: double-quote-string-fixer - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit rev: "v0.3.7" hooks: - id: ruff args: ["--fix", "--show-fixes"] pyproject-metadata-0.8.0/.readthedocs.yml000066400000000000000000000002271461011027700204020ustar00rootroot00000000000000version: 2 build: os: ubuntu-22.04 tools: python: "3.12" python: install: - method: pip path: . extra_requirements: [docs] pyproject-metadata-0.8.0/CHANGELOG.rst000066400000000000000000000043261461011027700173410ustar00rootroot00000000000000+++++++++ Changelog +++++++++ 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.8.0/LICENSE000066400000000000000000000021311461011027700163150ustar00rootroot00000000000000Copyright © 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.8.0/README.md000066400000000000000000000033661461011027700166020ustar00rootroot00000000000000# pyproject-metadata [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/pypa/pyproject-metadata/main.svg)](https://results.pre-commit.ci/latest/github/pypa/pyproject-metadata/main) [![checks](https://github.com/pypa/pyproject-metadata/actions/workflows/checks.yml/badge.svg)](https://github.com/FFY00/python-pyproject-metadata/actions/workflows/checks.yml) [![tests](https://github.com/pypa/pyproject-metadata/actions/workflows/tests.yml/badge.svg)](https://github.com/pypa/pyproject-metadata/actions/workflows/tests.yml) [![codecov](https://codecov.io/gh/pypa/pyproject-metadata/branch/main/graph/badge.svg?token=9chBjS1lch)](https://codecov.io/gh/pypa/pyproject-metadata) [![Documentation Status](https://readthedocs.org/projects/pyproject-metadata/badge/?version=latest)](https://pep621.readthedocs.io/en/latest/?badge=latest) > 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) print(metadata.entrypoints) # same fields as defined in PEP 621 pkg_info = metadata.as_rfc822() print(str(pkg_info)) # core metadata ``` [core metadata]: https://packaging.python.org/specifications/core-metadata/ pyproject-metadata-0.8.0/codecov.yml000066400000000000000000000000171461011027700174560ustar00rootroot00000000000000comment: false pyproject-metadata-0.8.0/docs/000077500000000000000000000000001461011027700162435ustar00rootroot00000000000000pyproject-metadata-0.8.0/docs/changelog.rst000077700000000000000000000000001461011027700231532../CHANGELOG.rstustar00rootroot00000000000000pyproject-metadata-0.8.0/docs/conf.py000066400000000000000000000043731461011027700175510ustar00rootroot00000000000000# 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 = [ '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 = ['_templates'] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [] default_role = 'any' # -- 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'] autoclass_content = 'both' pyproject-metadata-0.8.0/docs/index.rst000066400000000000000000000015161461011027700201070ustar00rootroot00000000000000:hide-toc: ****************** pyproject-metadata ****************** PEP 621 metadata parsing. API Reference ============= .. autoclass:: pyproject_metadata.StandardMetadata :members: :undoc-members: .. autoclass:: pyproject_metadata.ConfigurationError :members: :undoc-members: .. autoclass:: pyproject_metadata.License :members: :undoc-members: .. autoclass:: pyproject_metadata.Readme :members: :undoc-members: .. autoclass:: pyproject_metadata.RFC822Message :members: :undoc-members: .. autoclass:: pyproject_metadata.DataFetcher :members: :undoc-members: .. toctree:: :hidden: changelog .. toctree:: :caption: Project Links :hidden: Source Code Issue Tracker pyproject-metadata-0.8.0/noxfile.py000066400000000000000000000013671461011027700173400ustar00rootroot00000000000000# SPDX-License-Identifier: MIT import os import os.path import nox nox.options.sessions = ['mypy', 'test'] nox.options.reuse_existing_virtualenvs = True @nox.session(python='3.7') def mypy(session): session.install('.', 'mypy') session.run('mypy', '-p', 'pyproject_metadata') @nox.session(python=['3.7', '3.8', '3.9', '3.10', '3.11', '3.12']) def test(session): htmlcov_output = os.path.join(session.virtualenv.location, 'htmlcov') xmlcov_output = os.path.join(session.virtualenv.location, f'coverage-{session.python}.xml') session.install('.[test]') session.run( 'pytest', '--cov', f'--cov-report=html:{htmlcov_output}', f'--cov-report=xml:{xmlcov_output}', 'tests/', *session.posargs ) pyproject-metadata-0.8.0/pyproject.toml000066400000000000000000000047401461011027700202340ustar00rootroot00000000000000[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", ] dependencies = [ "packaging>=19.0", ] [project.optional-dependencies] docs = [ "furo>=2023.9.10", "sphinx-autodoc-typehints>=1.10.0", "sphinx~=7.0", ] test = [ "pytest-cov[toml]>=2", "pytest>=6.2.4", 'tomli>=1.0.0;python_version<"3.11"', ] [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/**", "CHANGELOG.rst"] [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] ignore_missing_imports = true strict = true [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 ] isort.known-first-party = ["pyproject-metadata"] isort.lines-after-imports = 2 isort.lines-between-types = 1 pylint.max-branches = 15 [tool.coverage] html.show_contexts = true report.exclude_also = [ "if typing.TYPE_CHECKING:", ] pyproject-metadata-0.8.0/pyproject_metadata/000077500000000000000000000000001461011027700211725ustar00rootroot00000000000000pyproject-metadata-0.8.0/pyproject_metadata/__init__.py000066400000000000000000000506221461011027700233100ustar00rootroot00000000000000# SPDX-License-Identifier: MIT from __future__ import annotations import collections import copy import dataclasses import email.utils import os import os.path import pathlib import sys import typing 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 import packaging.markers import packaging.requirements import packaging.specifiers import packaging.utils import packaging.version __version__ = '0.8.0' KNOWN_METADATA_VERSIONS = {'2.1', '2.2', '2.3'} class ConfigurationError(Exception): '''Error in the backend metadata.''' 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 RFC822Message: '''Python-flavored RFC 822 message implementation.''' def __init__(self) -> None: self.headers: collections.OrderedDict[str, list[str]] = collections.OrderedDict() self.body: str | None = None def __setitem__(self, name: str, value: str | None) -> None: if not value: return if name not in self.headers: self.headers[name] = [] self.headers[name].append(value) def __str__(self) -> str: text = '' for name, entries in self.headers.items(): for entry in entries: lines = entry.strip('\n').split('\n') text += f'{name}: {lines[0]}\n' for line in lines[1:]: text += ' ' * 8 + line + '\n' if self.body: text += '\n' + self.body return text def __bytes__(self) -> bytes: return str(self).encode() class DataFetcher: def __init__(self, data: Mapping[str, Any]) -> None: self._data = data def __contains__(self, key: Any) -> bool: if not isinstance(key, str): return False val = self._data try: for part in key.split('.'): val = val[part] except KeyError: return False return True def get(self, key: str) -> Any: val = self._data for part in key.split('.'): val = val[part] return val def get_str(self, key: str) -> str | None: try: val = self.get(key) if not isinstance(val, str): msg = f'Field "{key}" has an invalid type, expecting a string (got "{val}")' raise ConfigurationError(msg, key=key) return val except KeyError: return None def get_list(self, key: str) -> list[str]: try: val = self.get(key) if not isinstance(val, list): msg = f'Field "{key}" has an invalid type, expecting a list of strings (got "{val}")' raise ConfigurationError(msg, key=val) for item in val: if not isinstance(item, str): msg = f'Field "{key}" contains item with invalid type, expecting a string (got "{item}")' raise ConfigurationError(msg, key=key) return val except KeyError: return [] def get_dict(self, key: str) -> dict[str, str]: try: val = self.get(key) if not isinstance(val, dict): msg = f'Field "{key}" has an invalid type, expecting a dictionary of strings (got "{val}")' raise ConfigurationError(msg, key=key) for subkey, item in val.items(): if not isinstance(item, str): msg = f'Field "{key}.{subkey}" has an invalid type, expecting a string (got "{item}")' raise ConfigurationError(msg, key=f'{key}.{subkey}') return val except KeyError: return {} def get_people(self, key: str) -> list[tuple[str, str]]: try: val = self.get(key) if not ( isinstance(val, list) and all(isinstance(x, dict) for x in val) and all( isinstance(item, str) for items in [_dict.values() for _dict in val] for item in items ) ): msg = ( f'Field "{key}" has an invalid type, expecting a list of ' f'dictionaries containing the "name" and/or "email" keys (got "{val}")' ) raise ConfigurationError(msg, key=key) return [ (entry.get('name', 'Unknown'), entry.get('email')) for entry in val ] except KeyError: return [] class License(typing.NamedTuple): text: str file: pathlib.Path | None class Readme(typing.NamedTuple): text: str file: pathlib.Path | None content_type: str @dataclasses.dataclass class StandardMetadata: name: str version: packaging.version.Version | None = None description: str | None = None license: License | 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]] = dataclasses.field(default_factory=list) maintainers: list[tuple[str, str]] = 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[str] = dataclasses.field(default_factory=list) _metadata_version: str | None = None @property def metadata_version(self) -> str: if self._metadata_version is None: return '2.2' if self.dynamic else '2.1' return self._metadata_version @property def canonical_name(self) -> str: return packaging.utils.canonicalize_name(self.name) @classmethod def from_pyproject( cls, data: Mapping[str, Any], project_dir: str | os.PathLike[str] = os.path.curdir, metadata_version: str | None = None, ) -> Self: fetcher = DataFetcher(data) project_dir = pathlib.Path(project_dir) if 'project' not in fetcher: msg = 'Section "project" missing in pyproject.toml' raise ConfigurationError(msg) dynamic = fetcher.get_list('project.dynamic') if 'name' in dynamic: msg = 'Unsupported field "name" in "project.dynamic"' raise ConfigurationError(msg) for field in dynamic: if field in data['project']: msg = f'Field "project.{field}" declared as dynamic in "project.dynamic" but is defined' raise ConfigurationError(msg) name = fetcher.get_str('project.name') if not name: msg = 'Field "project.name" missing' raise ConfigurationError(msg) version_string = fetcher.get_str('project.version') requires_python_string = fetcher.get_str('project.requires-python') version = packaging.version.Version(version_string) if version_string else None if version is None and 'version' not in dynamic: msg = 'Field "project.version" missing and "version" not specified in "project.dynamic"' raise ConfigurationError(msg) # Description fills Summary, which cannot be multiline # However, throwing an error isn't backward compatible, # so leave it up to the users for now. description = fetcher.get_str('project.description') if metadata_version and metadata_version not in KNOWN_METADATA_VERSIONS: msg = f'The metadata_version must be one of {KNOWN_METADATA_VERSIONS} or None (default)' raise ConfigurationError(msg) return cls( name, version, description, cls._get_license(fetcher, project_dir), cls._get_readme(fetcher, project_dir), packaging.specifiers.SpecifierSet(requires_python_string) if requires_python_string else None, cls._get_dependencies(fetcher), cls._get_optional_dependencies(fetcher), cls._get_entrypoints(fetcher), fetcher.get_people('project.authors'), fetcher.get_people('project.maintainers'), fetcher.get_dict('project.urls'), fetcher.get_list('project.classifiers'), fetcher.get_list('project.keywords'), fetcher.get_dict('project.scripts'), fetcher.get_dict('project.gui-scripts'), dynamic, metadata_version, ) def _update_dynamic(self, value: Any) -> None: if value and 'version' in self.dynamic: self.dynamic.remove('version') def __setattr__(self, name: str, value: Any) -> None: # update dynamic when version is set if name == 'version' and hasattr(self, 'dynamic'): self._update_dynamic(value) super().__setattr__(name, value) def as_rfc822(self) -> RFC822Message: message = RFC822Message() self.write_to_rfc822(message) return message def write_to_rfc822(self, message: RFC822Message) -> None: # noqa: C901 message['Metadata-Version'] = self.metadata_version message['Name'] = self.name if not self.version: msg = 'Missing version field' raise ConfigurationError(msg) message['Version'] = str(self.version) # skip 'Platform' # skip 'Supported-Platform' if self.description: message['Summary'] = self.description message['Keywords'] = ','.join(self.keywords) if 'homepage' in self.urls: message['Home-page'] = self.urls['homepage'] # skip 'Download-URL' message['Author'] = self._name_list(self.authors) message['Author-Email'] = self._email_list(self.authors) message['Maintainer'] = self._name_list(self.maintainers) message['Maintainer-Email'] = self._email_list(self.maintainers) if self.license: message['License'] = self.license.text for classifier in self.classifiers: message['Classifier'] = classifier # skip 'Provides-Dist' # skip 'Obsoletes-Dist' # skip 'Requires-External' for name, url in self.urls.items(): message['Project-URL'] = f'{name.capitalize()}, {url}' if self.requires_python: message['Requires-Python'] = str(self.requires_python) for dep in self.dependencies: message['Requires-Dist'] = str(dep) for extra, requirements in self.optional_dependencies.items(): norm_extra = extra.replace('.', '-').replace('_', '-').lower() message['Provides-Extra'] = norm_extra for requirement in requirements: message['Requires-Dist'] = str(self._build_extra_req(norm_extra, requirement)) if self.readme: if self.readme.content_type: message['Description-Content-Type'] = self.readme.content_type message.body = self.readme.text # Core Metadata 2.2 if self.metadata_version != '2.1': for field in self.dynamic: if field in ('name', 'version'): msg = f'Field cannot be dynamic: {field}' raise ConfigurationError(msg) message['Dynamic'] = field def _name_list(self, people: list[tuple[str, str]]) -> str: return ', '.join( name for name, email_ in people if not email_ ) def _email_list(self, people: list[tuple[str, str]]) -> str: return ', '.join( email.utils.formataddr((name, _email)) for name, _email in people if _email ) def _build_extra_req( self, extra: str, requirement: Requirement, ) -> Requirement: # append or add our 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}"' ) else: requirement.marker = packaging.markers.Marker( f'{requirement.marker} and extra == "{extra}"' ) else: requirement.marker = packaging.markers.Marker(f'extra == "{extra}"') return requirement @staticmethod def _get_license(fetcher: DataFetcher, project_dir: pathlib.Path) -> License | None: if 'project.license' not in fetcher: return None _license = fetcher.get_dict('project.license') for field in _license: if field not in ('file', 'text'): msg = f'Unexpected field "project.license.{field}"' raise ConfigurationError(msg, key=f'project.license.{field}') file: pathlib.Path | None = None filename = fetcher.get_str('project.license.file') text = fetcher.get_str('project.license.text') if (filename and text) or (not filename and not text): msg = f'Invalid "project.license" value, expecting either "file" or "text" (got "{_license}")' raise ConfigurationError(msg, key='project.license') if filename: file = project_dir.joinpath(filename) if not file.is_file(): msg = f'License file not found ("{filename}")' raise ConfigurationError(msg, key='project.license.file') text = file.read_text(encoding='utf-8') assert text is not None return License(text, file) @staticmethod def _get_readme(fetcher: DataFetcher, project_dir: pathlib.Path) -> Readme | None: # noqa: C901 if 'project.readme' not in fetcher: return None filename: str | None file: pathlib.Path | None = None text: str | None content_type: str | None readme = fetcher.get('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 = f'Could not infer content type for readme file "{filename}"' raise ConfigurationError(msg, key='project.readme') 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 = f'Unexpected field "project.readme.{field}"' raise ConfigurationError(msg, key=f'project.readme.{field}') content_type = fetcher.get_str('project.readme.content-type') filename = fetcher.get_str('project.readme.file') text = fetcher.get_str('project.readme.text') if (filename and text) or (not filename and not text): msg = f'Invalid "project.readme" value, expecting either "file" or "text" (got "{readme}")' raise ConfigurationError(msg, key='project.readme') if not content_type: msg = 'Field "project.readme.content-type" missing' raise ConfigurationError(msg, key='project.readme.content-type') else: msg = ( f'Field "project.readme" has an invalid type, expecting either, ' f'a string or dictionary of strings (got "{readme}")' ) raise ConfigurationError(msg, key='project.readme') if filename: file = project_dir.joinpath(filename) if not file.is_file(): msg = f'Readme file not found ("{filename}")' raise ConfigurationError(msg, key='project.readme.file') text = file.read_text(encoding='utf-8') assert text is not None return Readme(text, file, content_type) @staticmethod def _get_dependencies(fetcher: DataFetcher) -> list[Requirement]: try: requirement_strings = fetcher.get_list('project.dependencies') except KeyError: return [] requirements: list[Requirement] = [] for req in requirement_strings: try: requirements.append(packaging.requirements.Requirement(req)) except packaging.requirements.InvalidRequirement as e: msg = ( 'Field "project.dependencies" contains an invalid PEP 508 ' f'requirement string "{req}" ("{e}")' ) raise ConfigurationError(msg) from None return requirements @staticmethod def _get_optional_dependencies(fetcher: DataFetcher) -> dict[str, list[Requirement]]: try: val = fetcher.get('project.optional-dependencies') except KeyError: return {} requirements_dict: dict[str, list[Requirement]] = {} if not isinstance(val, dict): msg = ( 'Field "project.optional-dependencies" has an invalid type, expecting a ' f'dictionary of PEP 508 requirement strings (got "{val}")' ) raise ConfigurationError(msg) for extra, requirements in val.copy().items(): assert isinstance(extra, str) if not isinstance(requirements, list): msg = ( f'Field "project.optional-dependencies.{extra}" has an invalid type, expecting a ' f'dictionary PEP 508 requirement strings (got "{requirements}")' ) raise ConfigurationError(msg) requirements_dict[extra] = [] for req in requirements: if not isinstance(req, str): msg = ( f'Field "project.optional-dependencies.{extra}" has an invalid type, ' f'expecting a PEP 508 requirement string (got "{req}")' ) raise ConfigurationError(msg) try: requirements_dict[extra].append(packaging.requirements.Requirement(req)) except packaging.requirements.InvalidRequirement as e: msg = ( f'Field "project.optional-dependencies.{extra}" contains ' f'an invalid PEP 508 requirement string "{req}" ("{e}")' ) raise ConfigurationError(msg) from None return dict(requirements_dict) @staticmethod def _get_entrypoints(fetcher: DataFetcher) -> dict[str, dict[str, str]]: try: val = fetcher.get('project.entry-points') except KeyError: return {} if not isinstance(val, dict): msg = ( 'Field "project.entry-points" has an invalid type, expecting a ' f'dictionary of entrypoint sections (got "{val}")' ) raise ConfigurationError(msg) for section, entrypoints in val.items(): assert isinstance(section, str) if not isinstance(entrypoints, dict): msg = ( f'Field "project.entry-points.{section}" has an invalid type, expecting a ' f'dictionary of entrypoints (got "{entrypoints}")' ) raise ConfigurationError(msg) for name, entrypoint in entrypoints.items(): assert isinstance(name, str) if not isinstance(entrypoint, str): msg = ( f'Field "project.entry-points.{section}.{name}" has an invalid type, ' f'expecting a string (got "{entrypoint}")' ) raise ConfigurationError(msg) return val pyproject-metadata-0.8.0/pyproject_metadata/py.typed000066400000000000000000000000001461011027700226570ustar00rootroot00000000000000pyproject-metadata-0.8.0/tests/000077500000000000000000000000001461011027700164555ustar00rootroot00000000000000pyproject-metadata-0.8.0/tests/__init__.py000066400000000000000000000000001461011027700205540ustar00rootroot00000000000000pyproject-metadata-0.8.0/tests/conftest.py000066400000000000000000000013241461011027700206540ustar00rootroot00000000000000# SPDX-License-Identifier: MIT import contextlib import os import pathlib import pytest package_dir = pathlib.Path(__file__).parent / 'packages' @contextlib.contextmanager def cd_package(package): cur_dir = os.getcwd() package_path = package_dir / package os.chdir(package_path) try: yield package_path finally: os.chdir(cur_dir) @pytest.fixture() def package(): with cd_package('full-metadata') as new_path: yield new_path @pytest.fixture() def package2(): with cd_package('full-metadata2') as new_path: yield new_path @pytest.fixture() def package_dynamic_description(): with cd_package('dynamic-description') as new_path: yield new_path pyproject-metadata-0.8.0/tests/packages/000077500000000000000000000000001461011027700202335ustar00rootroot00000000000000pyproject-metadata-0.8.0/tests/packages/dynamic-description/000077500000000000000000000000001461011027700242005ustar00rootroot00000000000000pyproject-metadata-0.8.0/tests/packages/dynamic-description/dynamic_description.py000066400000000000000000000000001461011027700305670ustar00rootroot00000000000000pyproject-metadata-0.8.0/tests/packages/dynamic-description/pyproject.toml000066400000000000000000000001321461011027700271100ustar00rootroot00000000000000[project] name = 'dynamic-description' version = '1.0.0' dynamic = [ 'description', ] pyproject-metadata-0.8.0/tests/packages/full-metadata/000077500000000000000000000000001461011027700227535ustar00rootroot00000000000000pyproject-metadata-0.8.0/tests/packages/full-metadata/README.md000066400000000000000000000000211461011027700242230ustar00rootroot00000000000000some readme 👋 pyproject-metadata-0.8.0/tests/packages/full-metadata/full_metadata.py000066400000000000000000000000001461011027700261150ustar00rootroot00000000000000pyproject-metadata-0.8.0/tests/packages/full-metadata/pyproject.toml000066400000000000000000000022641461011027700256730ustar00rootroot00000000000000[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.8.0/tests/packages/full-metadata2/000077500000000000000000000000001461011027700230355ustar00rootroot00000000000000pyproject-metadata-0.8.0/tests/packages/full-metadata2/LICENSE000066400000000000000000000000231461011027700240350ustar00rootroot00000000000000Some license! 👋 pyproject-metadata-0.8.0/tests/packages/full-metadata2/README.rst000066400000000000000000000000211461011027700245150ustar00rootroot00000000000000some readme 👋 pyproject-metadata-0.8.0/tests/packages/full-metadata2/full_metadata2.py000066400000000000000000000000001461011027700262610ustar00rootroot00000000000000pyproject-metadata-0.8.0/tests/packages/full-metadata2/pyproject.toml000066400000000000000000000022541461011027700257540ustar00rootroot00000000000000[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.8.0/tests/packages/unknown-readme-type/000077500000000000000000000000001461011027700241445ustar00rootroot00000000000000pyproject-metadata-0.8.0/tests/packages/unknown-readme-type/README.just-made-this-up-now000066400000000000000000000000141461011027700310770ustar00rootroot00000000000000some readme pyproject-metadata-0.8.0/tests/packages/unknown-readme-type/pyproject.toml000066400000000000000000000001411461011027700270540ustar00rootroot00000000000000[project] name = 'unknown-readme-type' version = '1.0.0' readme = 'README.just-made-this-up-now' pyproject-metadata-0.8.0/tests/packages/unknown-readme-type/unknown_readme_type.py000066400000000000000000000000001461011027700305610ustar00rootroot00000000000000pyproject-metadata-0.8.0/tests/test_rfc822.py000066400000000000000000000134131461011027700210760ustar00rootroot00000000000000# SPDX-License-Identifier: MIT import textwrap import pytest import pyproject_metadata @pytest.mark.parametrize( ('items', 'data'), [ # empty ([], ''), # simple ( [ ('Foo', 'Bar'), ], 'Foo: Bar\n', ), ( [ ('Foo', 'Bar'), ('Foo2', 'Bar2'), ], '''\ Foo: Bar Foo2: Bar2 ''', ), # None ( [ ('Item', None), ], '', ), # order ( [ ('ItemA', 'ValueA'), ('ItemB', 'ValueB'), ('ItemC', 'ValueC'), ], '''\ ItemA: ValueA ItemB: ValueB ItemC: ValueC ''', ), ( [ ('ItemB', 'ValueB'), ('ItemC', 'ValueC'), ('ItemA', 'ValueA'), ], '''\ ItemB: ValueB ItemC: ValueC ItemA: ValueA ''', ), # multiple keys ( [ ('ItemA', 'ValueA1'), ('ItemB', 'ValueB'), ('ItemC', 'ValueC'), ('ItemA', 'ValueA2'), ], '''\ ItemA: ValueA1 ItemA: ValueA2 ItemB: ValueB ItemC: ValueC ''', ), ( [ ('ItemA', 'ValueA'), ('ItemB', 'ValueB1\nValueB2\nValueB3'), ('ItemC', 'ValueC'), ], '''\ ItemA: ValueA ItemB: ValueB1 ValueB2 ValueB3 ItemC: ValueC ''' ), ], ) def test_headers(items, data): message = pyproject_metadata.RFC822Message() for name, value in items: message[name] = value data = textwrap.dedent(data) assert str(message) == data assert bytes(message) == data.encode() def test_body(): message = pyproject_metadata.RFC822Message() message['ItemA'] = 'ValueA' message['ItemB'] = 'ValueB' message['ItemC'] = 'ValueC' message.body = textwrap.dedent(''' 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. ''') assert str(message) == textwrap.dedent('''\ ItemA: ValueA ItemB: ValueB ItemC: ValueC 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. ''') def test_convert_optional_dependencies(): 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.headers['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(): 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.headers['Author-Email'] == [ '"John Doe, Inc." , "Kate Doe, LLC." ' ] pyproject-metadata-0.8.0/tests/test_standard_metadata.py000066400000000000000000000626271461011027700235430ustar00rootroot00000000000000# SPDX-License-Identifier: MIT import pathlib import re import textwrap import packaging.specifiers import packaging.version import pytest try: import tomllib except ImportError: import tomli as tomllib import pyproject_metadata from .conftest import cd_package @pytest.mark.parametrize( ('data', 'error'), [ ('', 'Section "project" missing in pyproject.toml'), # name ('[project]', 'Field "project.name" missing'), ( textwrap.dedent(''' [project] name = true version = '0.1.0' '''), ('Field "project.name" has an invalid type, expecting a string (got "True")'), ), # dynamic ( textwrap.dedent(''' [project] name = true version = '0.1.0' dynamic = [ 'name', ] '''), ('Unsupported field "name" in "project.dynamic"'), ), # version ( textwrap.dedent(''' [project] name = 'test' version = true '''), ('Field "project.version" has an invalid type, expecting a string (got "True")'), ), ( textwrap.dedent(''' [project] name = 'test' '''), ('Field "project.version" missing and "version" not specified in "project.dynamic"'), ), # license ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' license = true '''), ('Field "project.license" has an invalid type, expecting a dictionary of strings (got "True")'), ), ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' license = {} '''), ('Invalid "project.license" value, expecting either "file" or "text" (got "{}")'), ), ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' license = { file = '...', text = '...' } '''), ( 'Invalid "project.license" value, expecting either "file" ' "or \"text\" (got \"{'file': '...', 'text': '...'}\")" ), ), ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' license = { made-up = ':(' } '''), ( 'Unexpected field "project.license.made-up"' ), ), ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' license = { file = true } '''), ('Field "project.license.file" has an invalid type, expecting a string (got "True")'), ), ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' license = { text = true } '''), ('Field "project.license.text" has an invalid type, expecting a string (got "True")'), ), ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' license = { file = 'this-file-does-not-exist' } '''), ('License file not found ("this-file-does-not-exist")'), ), # readme ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' readme = true '''), ( 'Field "project.readme" has an invalid type, expecting either, ' 'a string or dictionary of strings (got "True")' ), ), ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' readme = {} '''), ( 'Invalid "project.readme" value, expecting either "file" or "text" (got "{}")' ), ), ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' readme = { file = '...', text = '...' } '''), ( 'Invalid "project.readme" value, expecting either "file" or ' "\"text\" (got \"{'file': '...', 'text': '...'}\")" ), ), ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' readme = { made-up = ':(' } '''), ( 'Unexpected field "project.readme.made-up"' ), ), ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' readme = { file = true } '''), ('Field "project.readme.file" has an invalid type, expecting a string (got "True")'), ), ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' readme = { text = true } '''), ('Field "project.readme.text" has an invalid type, expecting a string (got "True")'), ), ( textwrap.dedent(''' [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")'), ), ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' readme = { file = 'README.md' } '''), ('Field "project.readme.content-type" missing'), ), ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' readme = { text = '...' } '''), ('Field "project.readme.content-type" missing'), ), # description ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' description = true '''), ('Field "project.description" has an invalid type, expecting a string (got "True")'), ), # dependencies ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' dependencies = 'some string!' '''), ('Field "project.dependencies" has an invalid type, expecting a list of strings (got "some string!")'), ), ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' dependencies = [ 99, ] '''), ('Field "project.dependencies" contains item with invalid type, expecting a string (got "99")'), ), ( textwrap.dedent(''' [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!" ' ), ), # optional-dependencies ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' optional-dependencies = true '''), ( 'Field "project.optional-dependencies" has an invalid type, ' 'expecting a dictionary of PEP 508 requirement strings (got "True")' ), ), ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' [project.optional-dependencies] test = 'some string!' '''), ( 'Field "project.optional-dependencies.test" has an invalid type, ' 'expecting a dictionary PEP 508 requirement strings (got "some string!")' ), ), ( textwrap.dedent(''' [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 "True")' ), ), ( textwrap.dedent(''' [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!" ' ), ), # requires-python ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' requires-python = true '''), ('Field "project.requires-python" has an invalid type, expecting a string (got "True")'), ), # keywords ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' keywords = 'some string!' '''), ('Field "project.keywords" has an invalid type, expecting a list of strings (got "some string!")'), ), ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' keywords = [ true, ] '''), ('Field "project.keywords" contains item with invalid type, expecting a string (got "True")'), ), # authors ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' authors = {} '''), ( 'Field "project.authors" has an invalid type, expecting a list of ' 'dictionaries containing the "name" and/or "email" keys (got "{}")' ), ), ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' authors = [ true, ] '''), ( 'Field "project.authors" has an invalid type, expecting a list of ' 'dictionaries containing the "name" and/or "email" keys (got "[True]")' ), ), # maintainers ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' maintainers = {} '''), ( 'Field "project.maintainers" has an invalid type, expecting a list of ' 'dictionaries containing the "name" and/or "email" keys (got "{}")' ), ), ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' maintainers = [ 10 ] '''), ( 'Field "project.maintainers" has an invalid type, expecting a list of ' 'dictionaries containing the "name" and/or "email" keys (got "[10]")' ), ), # classifiers ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' classifiers = 'some string!' '''), ('Field "project.classifiers" has an invalid type, expecting a list of strings (got "some string!")'), ), ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' classifiers = [ true, ] '''), ('Field "project.classifiers" contains item with invalid type, expecting a string (got "True")'), ), # homepage ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' [project.urls] homepage = true '''), ('Field "project.urls.homepage" has an invalid type, expecting a string (got "True")'), ), # documentation ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' [project.urls] documentation = true '''), ('Field "project.urls.documentation" has an invalid type, expecting a string (got "True")'), ), # repository ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' [project.urls] repository = true '''), ('Field "project.urls.repository" has an invalid type, expecting a string (got "True")'), ), # changelog ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' [project.urls] changelog = true '''), ('Field "project.urls.changelog" has an invalid type, expecting a string (got "True")'), ), # scripts ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' scripts = [] '''), ('Field "project.scripts" has an invalid type, expecting a dictionary of strings (got "[]")'), ), # gui-scripts ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' gui-scripts = [] '''), ('Field "project.gui-scripts" has an invalid type, expecting a dictionary of strings (got "[]")'), ), # entry-points ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' entry-points = [] '''), ( 'Field "project.entry-points" has an invalid type, ' 'expecting a dictionary of entrypoint sections (got "[]")' ), ), ( textwrap.dedent(''' [project] name = 'test' version = '0.1.0' entry-points = { section = 'something' } '''), ( 'Field "project.entry-points.section" has an invalid type, ' 'expecting a dictionary of entrypoints (got "something")' ), ), ( textwrap.dedent(''' [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 "[]")'), ), ] ) @pytest.mark.usefixtures('package') def test_load(data, error): with pytest.raises(pyproject_metadata.ConfigurationError, match=re.escape(error)): pyproject_metadata.StandardMetadata.from_pyproject(tomllib.loads(data)) @pytest.mark.parametrize('after_rfc', [False, True]) @pytest.mark.usefixtures('package') def test_value(after_rfc): 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 metadata.license.file is None assert metadata.license.text == 'some license text' 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"', ] @pytest.mark.usefixtures('package2') def test_read_license(): with open('pyproject.toml', 'rb') as f: metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f)) 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, content_type): with cd_package(package), open('pyproject.toml', 'rb') as f: metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f)) assert metadata.readme.content_type == content_type def test_readme_content_type_unknown(): with cd_package('unknown-readme-type'), 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)) @pytest.mark.usefixtures('package') def test_as_rfc822(): with open('pyproject.toml', 'rb') as f: metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f)) core_metadata = metadata.as_rfc822() assert core_metadata.headers == { 'Metadata-Version': ['2.1'], 'Name': ['full_metadata'], 'Summary': ['A package with all the metadata :)'], 'Version': ['3.2.1'], 'Keywords': ['trampolim,is,interesting'], 'Home-page': ['example.com'], 'Author': ['Example!'], 'Author-Email': ['Unknown '], 'Maintainer-Email': ['Other Example '], 'License': ['some license text'], 'Classifier': [ 'Development Status :: 4 - Beta', 'Programming Language :: Python', ], 'Project-URL': [ 'Homepage, example.com', 'Documentation, readthedocs.org', 'Repository, github.com/some/repo', 'Changelog, github.com/some/repo/blob/master/CHANGELOG.rst', ], 'Requires-Python': ['>=3.8'], '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"', ], 'Description-Content-Type': ['text/markdown'], } assert core_metadata.body == 'some readme 👋\n' @pytest.mark.usefixtures('package_dynamic_description') def test_as_rfc822_dynamic(): with open('pyproject.toml', 'rb') as f: metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f)) core_metadata = metadata.as_rfc822() assert dict(core_metadata.headers) == { 'Metadata-Version': ['2.2'], 'Name': ['dynamic-description'], 'Version': ['1.0.0'], 'Dynamic': ['description'], } @pytest.mark.parametrize('metadata_version', ['2.1', '2.2', '2.3']) def test_as_rfc822_set_metadata(metadata_version): 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 = str(metadata.as_rfc822()) 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_rfc822_set_metadata_invalid(): 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(): metadata = pyproject_metadata.StandardMetadata( name='something', version=packaging.version.Version('1.0.0'), ) metadata.dynamic = ['name'] with pytest.raises(pyproject_metadata.ConfigurationError, match='Field cannot be dynamic: name'): metadata.as_rfc822() metadata.dynamic = ['version'] with pytest.raises(pyproject_metadata.ConfigurationError, match='Field cannot be dynamic: version'): metadata.as_rfc822() def test_as_rfc822_missing_version(): metadata = pyproject_metadata.StandardMetadata(name='something') with pytest.raises(pyproject_metadata.ConfigurationError, match='Missing version field'): metadata.as_rfc822() def test_stically_defined_dynamic_field(): 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): pyproject_metadata.StandardMetadata.from_pyproject({ 'project': { 'name': 'example', 'version': '0.1.0', 'requires-python': value, }, }) def test_version_dynamic(): metadata = pyproject_metadata.StandardMetadata.from_pyproject({ 'project': { 'name': 'example', 'dynamic': [ 'version', ], }, }) metadata.version = packaging.version.Version('1.2.3') assert 'version' not in metadata.dynamic