Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

docs: add more docs, polish presentation #186

Merged
merged 1 commit into from
Oct 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 3 additions & 10 deletions docs/api/pyproject_metadata.rst
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
pyproject\_metadata package
===========================
API Reference
=============

.. automodule:: pyproject_metadata
:members:
:undoc-members:
:show-inheritance:
:exclude-members: ConfigurationError

Submodules
----------
Expand Down Expand Up @@ -32,11 +33,3 @@ pyproject\_metadata.project\_table module
:members:
:undoc-members:
:show-inheritance:

pyproject\_metadata.pyproject module
------------------------------------

.. automodule:: pyproject_metadata.pyproject
:members:
:undoc-members:
:show-inheritance:
4 changes: 4 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@
# 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
Expand Down
39 changes: 16 additions & 23 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,37 @@

import nox

nox.options.sessions = ["mypy", "test"]
nox.needs_version = ">=2024.4.15"
nox.options.reuse_existing_virtualenvs = True

ALL_PYTHONS = [
c.split()[-1]
for c in nox.project.load_toml("pyproject.toml")["project"]["classifiers"]
if c.startswith("Programming Language :: Python :: 3.")
]


@nox.session(python="3.7")
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=["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"])
@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(".[test]")
session.install("-e.[test]")

session.run(
"pytest",
Expand All @@ -37,7 +49,7 @@ def test(session: nox.Session) -> None:
)


@nox.session()
@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.
Expand Down Expand Up @@ -68,22 +80,3 @@ def docs(session: nox.Session) -> None:
session.run("sphinx-autobuild", "--open-browser", *shared_args)
else:
session.run("sphinx-build", "--keep-going", *shared_args)


@nox.session()
def build_api_docs(session: nox.Session) -> None:
"""
Build (regenerate) API docs.
"""

session.install("sphinx")
session.chdir("docs")
session.run(
"sphinx-apidoc",
"-o",
"api/",
"--no-toc",
"--force",
"--module-first",
"../pyproject_metadata",
)
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ ignore = [
"PLR09", # Design related (too many X)
]


[tool.ruff.format]
docstring-code-format = true

[tool.coverage]
run.dynamic_context = "test_function"
Expand Down
202 changes: 122 additions & 80 deletions pyproject_metadata/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,32 @@

"""
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") 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
Expand Down Expand Up @@ -184,6 +210,12 @@ def as_bytes(

@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
Expand All @@ -207,14 +239,23 @@ class StandardMetadata:
"""
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 inbetween SDist and wheel. Requires metadata_version 2.2+.
"""

metadata_version: str | None = None
"""
Thi 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.
"""
_locked_metadata: bool = False
"""
Interal flag to prevent setting non-dynamic fields after initialization.
"""

def __post_init__(self) -> None:
self.validate()
Expand All @@ -228,85 +269,6 @@ def __setattr__(self, name: str, value: Any) -> None:
raise AttributeError(msg)
super().__setattr__(name, value)

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``. 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
"""
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)

# See https://packaging.python.org/en/latest/specifications/core-metadata/#name and
# https://packaging.python.org/en/latest/specifications/name-normalization/#name-format
if not re.match(
r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$", self.name, re.IGNORECASE
):
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")

errors.finalize("Metadata validation failed")

@property
def auto_metadata_version(self) -> str:
"""
Expand Down Expand Up @@ -502,6 +464,86 @@ def as_json(self) -> dict[str, str | list[str]]:
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
"""
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)

# See https://packaging.python.org/en/latest/specifications/core-metadata/#name and
# https://packaging.python.org/en/latest/specifications/name-normalization/#name-format
if not re.match(
r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$", self.name, re.IGNORECASE
):
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")

errors.finalize("Metadata validation failed")

def _write_metadata( # noqa: C901
self, smart_message: _SmartMessageSetter | _JSonMessageSetter
) -> None:
Expand Down
3 changes: 2 additions & 1 deletion pyproject_metadata/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ def __dir__() -> list[str]:


class ConfigurationError(Exception):
"""Error in the backend metadata."""
"""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)
Expand Down
10 changes: 10 additions & 0 deletions pyproject_metadata/pyproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,22 @@ def __dir__() -> list[str]:

@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
Expand Down