blob: 94073f53406d86062e47a53b596e1ae93fb6bfd7 [file] [edit]
"""Generate the "API changes" section of the release pages from the version histories of the API pages."""
# Every page below docs/api ends in a "Version history" section ("Added in version 3.8.0.", "... since version 3.13.0.").
# This hook collects these entries and replaces the `<!-- api-changes -->` marker of each page in home/releases with
# the pages that were added in that release and the changes made to existing pages. The page of a release is named
# after its version (v3.12.0.md); unreleased.md collects all versions newer than the latest release. The result is an
# overview of what changed in the API, not a change log.
import logging
import os
import posixpath
import re
log = logging.getLogger("mkdocs.hooks.api_changes")
_DIR = "home/releases"
_PAGE_VERSION = re.compile(r"v(\d+)\.(\d+)\.(\d+)\.md")
_MARKER = "<!-- api-changes -->"
_HISTORY = re.compile(r"^## Version history\n(.*?)(?=^## |\Z)", re.DOTALL | re.MULTILINE)
_TITLE = re.compile(r"^# (.+)$", re.MULTILINE)
_ITEM = re.compile(r"^(?:[-*]|\d+\.)\s+")
_SENTENCE = re.compile(r"(?<=\.)\s+(?=[A-Z`\[])")
_VERSION = re.compile(r"\b(?:version|since|in)\s+(\d+)\.(\d+)\.(\d+)\b", re.IGNORECASE)
_FUTURE = re.compile(r"\bwill\b|\bplanned\b", re.IGNORECASE) # statements about versions that do not exist yet
_NEXT_MAJOR = (4, 0, 0) # versions from here on are announcements, not changes
_ADDED = re.compile(r"^(?:Added|Introduced)\b.*\bversion \d|^Since version \d+\.\d+\.\d+\.?$", re.IGNORECASE)
_LINK = re.compile(r"(\]\()([^)\s]+)(\))")
def on_page_markdown(markdown, *, page, config, files):
uri = page.file.src_uri
if posixpath.dirname(uri) != _DIR or _MARKER not in markdown:
return markdown
docs_dir = config["docs_dir"]
entries = _collect(docs_dir)
released = sorted(tuple(map(int, m.groups())) for m in
(_PAGE_VERSION.fullmatch(name) for name in os.listdir(os.path.join(docs_dir, _DIR))) if m)
match = _PAGE_VERSION.fullmatch(posixpath.basename(uri))
if match:
versions = [tuple(map(int, match.groups()))]
else: # unreleased.md
versions = sorted((v for v in entries if not released or v > released[-1]), reverse=True)
return markdown.replace(_MARKER, _render(uri, [(v, entries[v]) for v in versions if v in entries]))
def _title(text, path):
match = _TITLE.search(text)
title = match.group(1) if match else os.path.splitext(os.path.basename(path))[0]
title = re.sub(r"</?small>", "", title)
title = title.replace("`", "").replace("\\", "").strip()
return title[len("nlohmann::"):] if title.startswith("nlohmann::") else title
def _collect(docs_dir):
"""Return {version: {"added": [(title, uri)], "changed": [(title, uri, text)]}}."""
result = {}
api = os.path.join(docs_dir, "api")
for root, _, names in sorted(os.walk(api)):
for name in sorted(names):
if not name.endswith(".md"):
continue
path = os.path.join(root, name)
uri = posixpath.relpath(path.replace(os.sep, "/"), docs_dir.replace(os.sep, "/"))
with open(path, encoding="utf-8") as source:
text = source.read()
history = _HISTORY.search(text)
if not history:
continue
title = _title(text, path)
found = _sentences(history.group(1))
# a page is added in the earliest version named by an "Added in" sentence that opens an entry (an entry of
# a numbered list describes one overload; later unnumbered entries extend an existing page)
starts = [v for i, n, first, v, sentence in found if first and _ADDED.match(sentence) and (i == 0 or n)]
added = min(starts) if starts else None
page_added = False
for index, number, first, version, sentence in found:
if version >= _NEXT_MAJOR:
continue
bucket = result.setdefault(version, {"added": [], "changed": []})
opens = first and _ADDED.match(sentence) and (index == 0 or number)
if opens and version == added:
if not page_added:
bucket["added"].append((title, uri))
page_added = True
continue
if number and re.fullmatch(r"Added in version [\d.]+\.?", sentence):
sentence = f"Overload {number} added."
elif number:
sentence = f"Overload {number}: {sentence}"
if (title, uri, sentence) not in bucket["changed"]:
bucket["changed"].append((title, uri, sentence))
return result
def _sentences(section):
"""Yield (entry index, overload number, opens entry, version, sentence) for every sentence naming a version."""
found = []
for index, (number, entry) in enumerate(_entries(section)):
for position, sentence in enumerate(_SENTENCE.split(entry)):
version = _VERSION.search(sentence)
if not version or _FUTURE.search(sentence):
continue
found.append((index, number, position == 0, tuple(int(part) for part in version.groups()), sentence.strip()))
return found
def _entries(section):
entries = []
for line in section.split("\n"):
item = _ITEM.match(line)
if item:
number = re.match(r"\d+", line)
entries.append([number.group(0) if number else None, _ITEM.sub("", line).strip()])
elif line.strip() and entries and line[0].isspace():
entries[-1][1] += " " + line.strip() # wrapped list item
elif line.strip():
break # text after the list, such as an admonition
return [tuple(entry) for entry in entries]
def _relink(text, uri, page):
"""Rewrite links relative to the API page `uri` so they work from the generated page."""
def fix(match):
target = match.group(2)
if re.match(r"[a-z]+:|#|/", target):
return match.group(0)
resolved = posixpath.normpath(posixpath.join(posixpath.dirname(uri), target))
return match.group(1) + posixpath.relpath(resolved, posixpath.dirname(page)) + match.group(3)
return _LINK.sub(fix, text)
def _link(title, uri, page):
return f"[`{title}`]({posixpath.relpath(uri, posixpath.dirname(page))})"
def _added(pages, page):
"""Group the added pages by class: `basic_json`: [`get`](..), ...; pages without a class come first."""
groups, single = {}, []
for title, uri in pages:
directory = posixpath.dirname(uri)
if directory == "api":
single.append(_link(title, uri, page))
elif posixpath.basename(uri) == "index.md":
groups.setdefault(directory, []).insert(0, f"[(overview)]({posixpath.relpath(uri, posixpath.dirname(page))})")
else:
groups.setdefault(directory, []).append(_link(title.split("::")[-1], uri, page))
lines = [f"- {item}" for item in single]
for directory, items in groups.items():
lines.append(f"- `{posixpath.basename(directory)}`: " + ", ".join(items))
return lines
def _render(page, entries):
lines = []
for version, bucket in entries:
if len(entries) > 1:
lines += [f"### Version {'.'.join(map(str, version))}", ""]
if bucket["added"]:
lines += [f"**Added ({len(bucket['added'])} pages):**", ""] + _added(bucket["added"], page) + [""]
if bucket["changed"]:
lines += ["**Changed:**", ""]
lines += [f"- {_link(t, u, page)}: {_relink(x, u, page)}" for t, u, x in bucket["changed"]] + [""]
return "\n".join(lines) if lines else "No documented API changes."