1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179
|
# Script to generate the project's credits.
from __future__ import annotations
import os
import sys
from collections import defaultdict
from collections.abc import Iterable
from importlib.metadata import distributions
from itertools import chain
from pathlib import Path
from textwrap import dedent
from typing import Union
from jinja2 import StrictUndefined
from jinja2.sandbox import SandboxedEnvironment
from packaging.requirements import Requirement
# YORE: EOL 3.10: Replace block with line 2.
if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib
project_dir = Path(os.getenv("MKDOCS_CONFIG_DIR", "."))
with project_dir.joinpath("pyproject.toml").open("rb") as pyproject_file:
pyproject = tomllib.load(pyproject_file)
project = pyproject["project"]
project_name = project["name"]
devdeps = [dep for group in pyproject["dependency-groups"].values() for dep in group if not dep.startswith("-e")]
PackageMetadata = dict[str, Union[str, Iterable[str]]]
Metadata = dict[str, PackageMetadata]
def _merge_fields(metadata: dict) -> PackageMetadata:
fields = defaultdict(list)
for header, value in metadata.items():
fields[header.lower()].append(value.strip())
return {
field: value if len(value) > 1 or field in ("classifier", "requires-dist") else value[0]
for field, value in fields.items()
}
def _norm_name(name: str) -> str:
return name.replace("_", "-").replace(".", "-").lower()
def _requirements(deps: list[str]) -> dict[str, Requirement]:
return {_norm_name((req := Requirement(dep)).name): req for dep in deps}
def _extra_marker(req: Requirement) -> str | None:
if not req.marker:
return None
try:
return next(marker[2].value for marker in req.marker._markers if getattr(marker[0], "value", None) == "extra")
except StopIteration:
return None
def _get_metadata() -> Metadata:
metadata = {}
for pkg in distributions():
name = _norm_name(pkg.name) # type: ignore[attr-defined,unused-ignore]
metadata[name] = _merge_fields(pkg.metadata) # type: ignore[arg-type]
metadata[name]["spec"] = set()
metadata[name]["extras"] = set()
metadata[name].setdefault("summary", "")
_set_license(metadata[name])
return metadata
def _set_license(metadata: PackageMetadata) -> None:
license_field = metadata.get("license-expression", metadata.get("license", ""))
license_name = license_field if isinstance(license_field, str) else " + ".join(license_field)
check_classifiers = license_name in ("UNKNOWN", "Dual License", "") or license_name.count("\n")
if check_classifiers:
license_names = []
for classifier in metadata["classifier"]:
if classifier.startswith("License ::"):
license_names.append(classifier.rsplit("::", 1)[1].strip())
license_name = " + ".join(license_names)
metadata["license"] = license_name or "?"
def _get_deps(base_deps: dict[str, Requirement], metadata: Metadata) -> Metadata:
deps = {}
for dep_name, dep_req in base_deps.items():
if dep_name not in metadata or dep_name == "mkdocstrings-python-legacy":
continue
metadata[dep_name]["spec"] |= {str(spec) for spec in dep_req.specifier} # type: ignore[operator]
metadata[dep_name]["extras"] |= dep_req.extras # type: ignore[operator]
deps[dep_name] = metadata[dep_name]
again = True
while again:
again = False
for pkg_name in metadata:
if pkg_name in deps:
for pkg_dependency in metadata[pkg_name].get("requires-dist", []):
requirement = Requirement(pkg_dependency)
dep_name = _norm_name(requirement.name)
extra_marker = _extra_marker(requirement)
if (
dep_name in metadata
and dep_name not in deps
and dep_name != project["name"]
and (not extra_marker or extra_marker in deps[pkg_name]["extras"])
):
metadata[dep_name]["spec"] |= {str(spec) for spec in requirement.specifier} # type: ignore[operator]
deps[dep_name] = metadata[dep_name]
again = True
return deps
def _render_credits() -> str:
metadata = _get_metadata()
dev_dependencies = _get_deps(_requirements(devdeps), metadata)
prod_dependencies = _get_deps(
_requirements(
chain( # type: ignore[arg-type]
project.get("dependencies", []),
chain(*project.get("optional-dependencies", {}).values()),
),
),
metadata,
)
template_data = {
"project_name": project_name,
"prod_dependencies": sorted(prod_dependencies.values(), key=lambda dep: str(dep["name"]).lower()),
"dev_dependencies": sorted(dev_dependencies.values(), key=lambda dep: str(dep["name"]).lower()),
"more_credits": "http://pawamoy.github.io/credits/",
}
template_text = dedent(
"""
# Credits
These projects were used to build *{{ project_name }}*. **Thank you!**
[Python](https://www.python.org/) |
[uv](https://github.com/astral-sh/uv) |
[copier-uv](https://github.com/pawamoy/copier-uv)
{% macro dep_line(dep) -%}
[{{ dep.name }}](https://pypi.org/project/{{ dep.name }}/) | {{ dep.summary }} | {{ ("`" ~ dep.spec|sort(reverse=True)|join(", ") ~ "`") if dep.spec else "" }} | `{{ dep.version }}` | {{ dep.license }}
{%- endmacro %}
{% if prod_dependencies -%}
### Runtime dependencies
Project | Summary | Version (accepted) | Version (last resolved) | License
------- | ------- | ------------------ | ----------------------- | -------
{% for dep in prod_dependencies -%}
{{ dep_line(dep) }}
{% endfor %}
{% endif -%}
{% if dev_dependencies -%}
### Development dependencies
Project | Summary | Version (accepted) | Version (last resolved) | License
------- | ------- | ------------------ | ----------------------- | -------
{% for dep in dev_dependencies -%}
{{ dep_line(dep) }}
{% endfor %}
{% endif -%}
{% if more_credits %}**[More credits from the author]({{ more_credits }})**{% endif %}
""",
)
jinja_env = SandboxedEnvironment(undefined=StrictUndefined)
return jinja_env.from_string(template_text).render(**template_data)
print(_render_credits())
|