"""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 `` 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 = "" _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"", "", 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."