Skip to content

Commit

Permalink
docs: add more docs, polish presentation (#186)
Browse files Browse the repository at this point in the history
Signed-off-by: Henry Schreiner <[email protected]>
  • Loading branch information
henryiii authored Oct 4, 2024
1 parent 4665e09 commit 1a0be7d
Show file tree
Hide file tree
Showing 7 changed files with 159 additions and 115 deletions.
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

0 comments on commit 1a0be7d

Please sign in to comment.