From 3f1844abf29033a0fb08f7858e364e40cf06877f Mon Sep 17 00:00:00 2001 From: Christopher Barber Date: Sat, 21 Feb 2026 10:09:24 -0500 Subject: [PATCH 1/9] Add initial copilot instructions file --- .github/copilot-instructions.md | 64 +++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..99872d7 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,64 @@ +# Copilot Instructions + +## Project Overview + +mkdocstrings-python-xref is an mkdocstrings handler that extends the standard +`mkdocstrings-python` handler to support relative cross-reference syntax in Python +docstrings. It also reports source locations for bad references. + +## Build, Test, and Lint + +All tasks use [pixi](https://pixi.sh/). Run `pixi task list` to see all available tasks. + +```bash +# Run tests with verbose output +pixi run pytest + +# Run a single test file +pixi run pytest -sv -ra tests/test_crossref.py + +# Run a single test by name +pixi run pytest -sv -ra tests/test_crossref.py -k "test_name" + +# Lint (ruff + mypy) +pixi run lint + +# Type checking only +pixi run mypy + +# Ruff linting only +pixi run ruff + +# Build docs +pixi run doc + +# Serve docs locally +pixi run show-doc +``` + +## Architecture + +- **Namespace package**: `src/mkdocstrings_handlers/` is an implicit namespace package + (no `__init__.py`). The handler lives under `python_xref/`. +- **Handler registration**: `get_handler()` factory in `__init__.py` returns a + `PythonRelXRefHandler` instance. mkdocstrings discovers it via the `python_xref` + handler name. +- **Core classes**: + - `PythonRelXRefHandler` (handler.py) — extends `PythonHandler`, overrides `render()` + to process relative cross-references before rendering. + - `_RelativeCrossrefProcessor` (crossref.py) — visitor-style processor that walks + Griffe docstring objects and substitutes relative refs using compiled regex patterns. +- **Version**: stored as plain text in `src/mkdocstrings_handlers/python_xref/VERSION`. + Hatchling reads it at build time. Versioning tracks the upstream mkdocstrings-python + version. + +## Conventions + +- **Build system**: Hatchling. The wheel includes `src/mkdocstrings_handlers`. +- **Docstring style**: Google style (enforced by ruff `D` rules and mypy). +- **Formatting**: Use `black` for code formatting. +- **Type annotations**: Required on all function definitions (`disallow_untyped_defs` + and `disallow_incomplete_defs` in mypy config). +- **Test organization**: Tests are in `tests/`. `tests/project/` contains a sample + Python package used by integration tests. There is no `conftest.py`; fixtures come + from pytest builtins. From 961cc0a12a3a502fc61c56a2bed7088f093fd628 Mon Sep 17 00:00:00 2001 From: Christopher Barber Date: Sat, 21 Feb 2026 10:15:38 -0500 Subject: [PATCH 2/9] Add compatibility_check and compatibility_patch options (#60) Add options to detect and convert xref-only cross-reference syntax to facilitate migration to standard mkdocstrings-python handler: - compatibility_check (false|"warn"|"error"): emit warnings or errors for incompatible syntax (^, (c), (m), (p), trailing ., leading ?) - compatibility_patch (false|filepath): generate a unified diff patch file converting incompatible refs to standard dot-prefix form. Removes stale patch file when no incompatibilities are found. Trailing '.' is only flagged as incompatible when it follows a name (alphanumeric character). Expressions like '...' are compatible, but '..foo.' is not. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../python_xref/crossref.py | 194 +++++++++++++++--- .../python_xref/handler.py | 149 +++++++++++++- tests/test_crossref.py | 172 +++++++++++++++- tests/test_handler.py | 96 +++++++++ 4 files changed, 580 insertions(+), 31 deletions(-) diff --git a/src/mkdocstrings_handlers/python_xref/crossref.py b/src/mkdocstrings_handlers/python_xref/crossref.py index af22d42..c010594 100644 --- a/src/mkdocstrings_handlers/python_xref/crossref.py +++ b/src/mkdocstrings_handlers/python_xref/crossref.py @@ -17,17 +17,37 @@ import ast import re +from dataclasses import dataclass, field from typing import Callable, List, Optional, cast from griffe import Alias, Docstring, GriffeError, Object from mkdocstrings import get_logger __all__ = [ - "substitute_relative_crossrefs" + "IncompatibleRef", + "substitute_relative_crossrefs", ] logger = get_logger(__name__) + +@dataclass +class IncompatibleRef: + """Record of a cross-reference using xref-only syntax.""" + + filepath: str + """Source file path.""" + line: int + """Line number in source file (1-based).""" + col: int + """Column number in source file (1-based).""" + original: str + """Original cross-reference text, e.g. ``[title][ref]``.""" + replacement: str + """Standard-compatible replacement text.""" + reasons: list[str] = field(default_factory=list) + """Description of incompatible syntax elements found.""" + def _re_or(*exps: str) -> str: """Construct an "or" regular expression from a sequence of regular expressions. @@ -116,8 +136,15 @@ class _RelativeCrossrefProcessor: _cur_ref_parts: List[str] _ok: bool _check_ref: Callable[[str], bool] - - def __init__(self, doc: Docstring, checkref: Optional[Callable[[str], bool]] = None): + _incompatible_refs: list[IncompatibleRef] | None + _cur_incompat_reasons: list[str] + + def __init__( + self, + doc: Docstring, + checkref: Optional[Callable[[str], bool]] = None, + incompatible_refs: Optional[list[IncompatibleRef]] = None, + ): self._doc = doc self._cur_match = None self._cur_input = "" @@ -125,6 +152,8 @@ def __init__(self, doc: Docstring, checkref: Optional[Callable[[str], bool]] = N self._cur_ref_parts = [] self._check_ref = checkref or _always_ok self._ok = True + self._incompatible_refs = incompatible_refs + self._cur_incompat_reasons = [] def __call__(self, match: re.Match) -> str: """ @@ -138,28 +167,32 @@ def __call__(self, match: re.Match) -> str: title = match[1] ref = match[2] + original_ref = ref checkref = self._check_ref - if ref.startswith("?"): + has_question_prefix = ref.startswith("?") + if has_question_prefix: # Turn off cross-ref check ref = ref[1:] checkref = _always_ok + self._add_incompat_reason("leading '?' suppresses reference checking") new_ref = "" - - # TODO support special syntax to turn off checking + std_ref_parts: list[str] = [] if not _RE_REL_CROSSREF.fullmatch(match.group(0)): # Just a regular cross reference new_ref = ref if ref else title + if has_question_prefix: + std_ref_parts.append(ref if ref else title) else: ref_match = _RE_REL.fullmatch(ref) if ref_match is None: self._error(f"Bad syntax in relative cross reference: '{ref}'") else: - self._process_parent_specifier(ref_match) - self._process_relname(ref_match) - self._process_append_from_title(ref_match, title) + self._process_parent_specifier(ref_match, std_ref_parts) + self._process_relname(ref_match, std_ref_parts) + self._process_append_from_title(ref_match, title, std_ref_parts) if self._ok: new_ref = '.'.join(self._cur_ref_parts) @@ -177,6 +210,13 @@ def __call__(self, match: re.Match) -> str: else: result = match.group(0) + # Record incompatibility if any xref-only syntax was found + if self._cur_incompat_reasons and self._incompatible_refs is not None and self._ok: + std_ref = _assemble_std_ref(std_ref_parts) if std_ref_parts else new_ref + self._record_incompatible_ref( + match, original_ref, f"[{title}][{std_ref}]" + ) + return result def _start_match(self, match: re.Match) -> None: @@ -185,21 +225,57 @@ def _start_match(self, match: re.Match) -> None: self._cur_input = match[0] self._ok = True self._cur_ref_parts.clear() + self._cur_incompat_reasons.clear() + + def _add_incompat_reason(self, reason: str) -> None: + """Record an incompatibility reason for the current match.""" + self._cur_incompat_reasons.append(reason) - def _process_relname(self, ref_match: re.Match) -> None: + def _record_incompatible_ref( + self, match: re.Match, original_ref: str, replacement: str, + ) -> None: + """Record an incompatible cross-reference.""" + if self._incompatible_refs is None: + return + doc = self._doc + parent = doc.parent + filepath = str(parent.filepath) if parent is not None else "" + line, col = doc_value_offset_to_location(doc, self._cur_offset) + self._incompatible_refs.append(IncompatibleRef( + filepath=filepath, + line=line, + col=col, + original=match.group(0), + replacement=replacement, + reasons=list(self._cur_incompat_reasons), + )) + + def _process_relname(self, ref_match: re.Match, std_ref_parts: list[str]) -> None: relname = ref_match.group("relname").strip(".") if relname: self._cur_ref_parts.append(relname) + std_ref_parts.append(relname) - def _process_append_from_title(self, ref_match: re.Match, title_text: str) -> None: + def _process_append_from_title( + self, ref_match: re.Match, title_text: str, std_ref_parts: list[str], + ) -> None: if ref_match.group(0).endswith("."): id_from_title = title_text.strip("`*") if not _RE_ID.fullmatch(id_from_title): self._error(f"Relative cross reference text is not a qualified identifier: '{id_from_title}'") return self._cur_ref_parts.append(id_from_title) - - def _process_parent_specifier(self, ref_match: re.Match) -> None: + # Trailing '.' after a name is xref-only "append title" syntax. + # After non-alphanumeric (e.g. ')' in (m).) it's just a separator + # absorbed by the dot prefix in standard form. + ref_text = ref_match.group(0) + if len(ref_text) >= 2 and ref_text[-2].isalnum(): + self._add_incompat_reason("trailing '.' appends title to reference") + std_ref_parts.append(id_from_title) + + def _process_parent_specifier( + self, ref_match: re.Match, std_ref_parts: list[str], + ) -> None: if not ref_match.group("parent"): return @@ -209,17 +285,19 @@ def _process_parent_specifier(self, ref_match: re.Match) -> None: return rel_obj = ( - self._process_current_specifier(obj, ref_match) - or self._process_class_specifier(obj, ref_match) - or self._process_module_specifier(obj, ref_match) - or self._process_package_specifier(obj, ref_match) - or self._process_up_specifier(obj, ref_match) + self._process_current_specifier(obj, ref_match, std_ref_parts) + or self._process_class_specifier(obj, ref_match, std_ref_parts) + or self._process_module_specifier(obj, ref_match, std_ref_parts) + or self._process_package_specifier(obj, ref_match, std_ref_parts) + or self._process_up_specifier(obj, ref_match, std_ref_parts) ) if rel_obj is not None and self._ok: self._cur_ref_parts.append(rel_obj.canonical_path) - def _process_current_specifier(self, obj: Object, ref_match: re.Match) -> Optional[Object]: + def _process_current_specifier( + self, obj: Object, ref_match: re.Match, std_ref_parts: list[str], + ) -> Optional[Object]: rel_obj: Object | None = None if ref_match.group("current"): if obj.is_function: @@ -229,43 +307,64 @@ def _process_current_specifier(self, obj: Object, ref_match: re.Match) -> Option ) else: rel_obj = obj + std_ref_parts.append('.') return rel_obj - def _process_class_specifier(self, obj: Object, ref_match: re.Match) -> Optional[Object]: + def _process_class_specifier( + self, obj: Object, ref_match: re.Match, std_ref_parts: list[str], + ) -> Optional[Object]: rel_obj: Object | None = None if ref_match.group("class"): rel_obj = obj + levels = 0 while not rel_obj.is_class: rel_obj = rel_obj.parent + levels += 1 if rel_obj is None: self._error(f"{obj.canonical_path} not in a class") break + if rel_obj is not None: + self._add_incompat_reason("'(c)' class specifier") + std_ref_parts.append('.' * (levels + 1)) return rel_obj - def _process_module_specifier(self, obj: Object, ref_match: re.Match) -> Optional[Object]: + def _process_module_specifier( + self, obj: Object, ref_match: re.Match, std_ref_parts: list[str], + ) -> Optional[Object]: rel_obj: Object | None = None if ref_match.group("module"): rel_obj = obj + levels = 0 while not rel_obj.is_module: rel_obj = rel_obj.parent + levels += 1 if rel_obj is None: # pragma: no cover self._error(f"{obj.canonical_path} not in a module!") break + if rel_obj is not None: + self._add_incompat_reason("'(m)' module specifier") + std_ref_parts.append('.' * (levels + 1)) return rel_obj - def _process_package_specifier(self, obj: Object, ref_match: re.Match) -> Optional[Object]: + def _process_package_specifier( + self, obj: Object, ref_match: re.Match, std_ref_parts: list[str], + ) -> Optional[Object]: # griffe does not distinguish between modules and packages, so we identify a package # as a module that contains other modules. A module that has no parent is considered to # be a package even if it does not contain modules. rel_obj: Object | None = None if ref_match.group("package"): rel_obj = obj + levels = 0 if rel_obj.is_module and rel_obj.modules: # module contains modules, so it is a package + self._add_incompat_reason("'(p)' package specifier") + std_ref_parts.append('.' * (levels + 1)) return rel_obj while not rel_obj.is_module: rel_obj = rel_obj.parent + levels += 1 if rel_obj is None: # pragma: no cover self._error(f"{obj.canonical_path} not in a module!") break @@ -273,20 +372,34 @@ def _process_package_specifier(self, obj: Object, ref_match: re.Match) -> Option if rel_obj is not None and rel_obj.parent is not None: # pragma: no branch # If module has no parent, we will treat it as a package rel_obj = rel_obj.parent + levels += 1 + + if rel_obj is not None: + self._add_incompat_reason("'(p)' package specifier") + std_ref_parts.append('.' * (levels + 1)) return rel_obj - def _process_up_specifier(self, obj: Object, ref_match: re.Match) -> Optional[Object]: + def _process_up_specifier( + self, obj: Object, ref_match: re.Match, std_ref_parts: list[str], + ) -> Optional[Object]: rel_obj: Object | None = None if ref_match.group("up"): - level = len(ref_match.group("up")) + up_text = ref_match.group("up") + level = len(up_text) + uses_caret = '^' in up_text rel_obj = obj for _ in range(level): if rel_obj.parent is not None: rel_obj = rel_obj.parent else: - self._error(f"'{ref_match.group('up')}' has too many levels for {obj.canonical_path}") + self._error(f"'{up_text}' has too many levels for {obj.canonical_path}") break + if rel_obj is not None: + if uses_caret: + self._add_incompat_reason("'^' caret specifier") + # Standard dot-prefix equivalent: level+1 dots + std_ref_parts.append('.' * (level + 1)) return rel_obj def _error(self, msg: str, just_warn: bool = False) -> None: @@ -317,9 +430,27 @@ def _error(self, msg: str, just_warn: bool = False) -> None: self._ok = just_warn +def _assemble_std_ref(parts: list[str]) -> str: + """Assemble a standard cross-reference from parts. + + The first element may be a dot-prefix (e.g., ``..``), which is concatenated + directly with the remaining parts (joined by ``'.'``). + """ + if not parts: + return "" + prefix = parts[0] + rest = parts[1:] + if prefix and all(c == '.' for c in prefix): + # Dot prefix: concatenate directly with rest + return prefix + '.'.join(rest) + # Regular name parts: join all with '.' + return '.'.join(parts) + + def substitute_relative_crossrefs( obj: Alias|Object, checkref: Optional[Callable[[str], bool]] = None, + incompatible_refs: Optional[list[IncompatibleRef]] = None, ) -> None: """Recursively expand relative cross-references in all docstrings in tree. @@ -327,6 +458,8 @@ def substitute_relative_crossrefs( obj: a Griffe [Object][griffe.] whose docstrings should be modified checkref: optional function to check whether computed cross-reference is valid. Should return True if valid, False if not valid. + incompatible_refs: if provided, incompatible cross-references will be appended + to this list. """ if isinstance(obj, Alias): try: @@ -339,11 +472,18 @@ def substitute_relative_crossrefs( doc = obj.docstring if doc is not None: - doc.value = _RE_CROSSREF.sub(_RelativeCrossrefProcessor(doc, checkref=checkref), doc.value) + doc.value = _RE_CROSSREF.sub( + _RelativeCrossrefProcessor( + doc, checkref=checkref, incompatible_refs=incompatible_refs, + ), + doc.value, + ) for member in obj.members.values(): if isinstance(member, (Alias,Object)): # pragma: no branch - substitute_relative_crossrefs(member, checkref=checkref) + substitute_relative_crossrefs( + member, checkref=checkref, incompatible_refs=incompatible_refs, + ) def doc_value_offset_to_location(doc: Docstring, offset: int) -> tuple[int,int]: """ diff --git a/src/mkdocstrings_handlers/python_xref/handler.py b/src/mkdocstrings_handlers/python_xref/handler.py index 3ed10cf..fed14dd 100644 --- a/src/mkdocstrings_handlers/python_xref/handler.py +++ b/src/mkdocstrings_handlers/python_xref/handler.py @@ -17,18 +17,22 @@ from __future__ import annotations +import atexit +import difflib +import logging import re +from collections import defaultdict from dataclasses import dataclass, field, fields from functools import partial from pathlib import Path -from typing import Any, ClassVar, Mapping, MutableMapping, Optional +from typing import Any, ClassVar, Literal, Mapping, MutableMapping, Optional from warnings import warn from mkdocs.config.defaults import MkDocsConfig from mkdocstrings import CollectorItem, get_logger from mkdocstrings_handlers.python import PythonHandler, PythonOptions, PythonConfig -from .crossref import substitute_relative_crossrefs +from .crossref import IncompatibleRef, substitute_relative_crossrefs __all__ = [ 'PythonRelXRefHandler' @@ -40,6 +44,8 @@ class PythonRelXRefOptions(PythonOptions): check_crossrefs: bool = True check_crossrefs_exclude: list[str | re.Pattern] = field(default_factory=list) + compatibility_check: Literal[False, "warn", "error"] = False + compatibility_patch: str | Literal[False] = False class PythonRelXRefHandler(PythonHandler): """Extended version of mkdocstrings Python handler @@ -62,7 +68,16 @@ def __init__(self, config: PythonConfig, base_dir: Path, **kwargs: Any) -> None: self.check_crossrefs = config.options.pop('check_crossrefs', True) exclude = config.options.pop('check_crossrefs_exclude', []) self.check_crossrefs_exclude = [re.compile(p) for p in exclude] + self.compatibility_check: Literal[False, "warn", "error"] = ( + config.options.pop('compatibility_check', False) + ) + self.compatibility_patch: str | Literal[False] = ( + config.options.pop('compatibility_patch', False) + ) + self._incompatible_refs: list[IncompatibleRef] = [] super().__init__(config, base_dir, **kwargs) + if self.compatibility_patch: + atexit.register(self._write_patch_file) def get_options(self, local_options: Mapping[str, Any]) -> PythonRelXRefOptions: local_options = dict(local_options) @@ -70,10 +85,16 @@ def get_options(self, local_options: Mapping[str, Any]) -> PythonRelXRefOptions: 'check_crossrefs', self.check_crossrefs) check_crossrefs_exclude = local_options.pop( 'check_crossrefs_exclude', self.check_crossrefs_exclude) + compatibility_check = local_options.pop( + 'compatibility_check', self.compatibility_check) + compatibility_patch = local_options.pop( + 'compatibility_patch', self.compatibility_patch) _opts = super().get_options(local_options) opts = PythonRelXRefOptions( check_crossrefs=check_crossrefs, check_crossrefs_exclude=check_crossrefs_exclude, + compatibility_check=compatibility_check, + compatibility_patch=compatibility_patch, **{field.name: getattr(_opts, field.name) for field in fields(_opts)} ) return opts @@ -85,7 +106,20 @@ def render(self, data: CollectorItem, options: PythonOptions, locale: str | None self._check_ref, exclude=options.check_crossrefs_exclude) else: checkref = None - substitute_relative_crossrefs(data, checkref=checkref) + + # Collect incompatible refs if compatibility features are enabled + incompat: list[IncompatibleRef] | None = None + if isinstance(options, PythonRelXRefOptions) and ( + options.compatibility_check or options.compatibility_patch + ): + incompat = [] + + substitute_relative_crossrefs( + data, checkref=checkref, incompatible_refs=incompat, + ) + + if incompat and isinstance(options, PythonRelXRefOptions): + self._handle_incompatible_refs(incompat, options) try: return super().render(data, options, locale=locale) @@ -93,6 +127,39 @@ def render(self, data: CollectorItem, options: PythonOptions, locale: str | None print(f"{data.path=}") raise + def _handle_incompatible_refs( + self, + refs: list[IncompatibleRef], + options: PythonRelXRefOptions, + ) -> None: + """Report and/or record incompatible cross-references. + + Arguments: + refs: list of incompatible cross-references found + options: handler options + """ + if options.compatibility_check: + if options.compatibility_check == "error": + level = logging.ERROR + else: + level = logging.WARNING + for ref in refs: + prefix = f"file://{ref.filepath}:" + if ref.line >= 0: + prefix += f"{ref.line}:" + if ref.col >= 0: + prefix += f"{ref.col}:" + prefix += " \n" + reasons = ", ".join(ref.reasons) + logger.log( + level, + "%sIncompatible cross-reference %s (%s)", + prefix, ref.original, reasons, + ) + + if options.compatibility_patch: + self._incompatible_refs.extend(refs) + def get_templates_dir(self, handler: Optional[str] = None) -> Path: """See [render][.barf]""" # use same templates as standard python handler @@ -118,6 +185,82 @@ def _check_ref(self, ref : str, exclude: list[str | re.Pattern] = []) -> bool: # Only expect a CollectionError but we may as well catch everything. return False + def _write_patch_file(self) -> None: + """Write or remove the compatibility patch file. + + If incompatible refs were collected, writes a unified diff patch to the + path specified by `compatibility_patch`. If no incompatibilities were + found, removes any existing patch file from a previous run. + """ + if not self.compatibility_patch: + return + + patch_path = Path(self.compatibility_patch) + + if not self._incompatible_refs: + if patch_path.exists(): + patch_path.unlink() + logger.info("Removed existing scompatibility patch %s (no incompatibilities found)", patch_path) + return + + patches = _generate_patch(self._incompatible_refs) + patch_path.write_text(patches, encoding="utf-8") + logger.info("Wrote compatibility patch to %s", patch_path) + + +def _generate_patch(refs: list[IncompatibleRef]) -> str: + """Generate a unified diff patch from incompatible cross-references. + + Arguments: + refs: list of incompatible cross-references + + Returns: + unified diff patch string + """ + # Group refs by filepath + by_file: dict[str, list[IncompatibleRef]] = defaultdict(list) + for ref in refs: + by_file[ref.filepath].append(ref) + + patch_parts: list[str] = [] + for filepath, file_refs in sorted(by_file.items()): + try: + original_lines = Path(filepath).read_text(encoding="utf-8").splitlines(keepends=True) + except OSError: + logger.warning("Cannot read file for patch: %s", filepath) + continue + + modified_lines = list(original_lines) + # Sort refs by line (descending) to apply replacements from bottom to top + for ref in sorted(file_refs, key=lambda r: (r.line, r.col), reverse=True): + if ref.line < 1 or ref.line > len(modified_lines): + # line is -1 when docstring has no line number info + logger.warning( + "Cannot patch %s: no source location for %s", + filepath, ref.original, + ) + continue + line_idx = ref.line - 1 + line = modified_lines[line_idx] + # Use column position for accurate replacement + col_offset = ref.col - 1 if ref.col >= 1 else 0 + idx = line.find(ref.original, col_offset) + if idx >= 0: + modified_lines[line_idx] = ( + line[:idx] + ref.replacement + line[idx + len(ref.original):] + ) + + if original_lines != modified_lines: + diff = difflib.unified_diff( + original_lines, + modified_lines, + fromfile=f"a/{filepath}", + tofile=f"b/{filepath}", + ) + patch_parts.append(''.join(diff)) + + return '\n'.join(patch_parts) + def get_handler( handler_config: MutableMapping[str, Any], tool_config: MkDocsConfig, diff --git a/tests/test_crossref.py b/tests/test_crossref.py index fa3a768..6789672 100644 --- a/tests/test_crossref.py +++ b/tests/test_crossref.py @@ -1,4 +1,4 @@ -# Copyright (c) 2022-2025. Analog Devices Inc. +# Copyright (c) 2022-2026. Analog Devices Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -29,6 +29,7 @@ # noinspection PyProtectedMember from mkdocstrings_handlers.python_xref.crossref import ( + IncompatibleRef, _RE_CROSSREF, _RE_REL_CROSSREF, _RelativeCrossrefProcessor, @@ -280,3 +281,172 @@ def test_griffe() -> None: ) substitute_relative_crossrefs(myproj) # TODO - grovel output + + +def test_incompatible_refs() -> None: + """Test detection of incompatible cross-references.""" + mod1 = Module(name="mod1", filepath=Path("mod1.py")) + mod2 = Module(name="mod2", parent=mod1, filepath=Path("mod2.py")) + mod1.members.update(mod2=mod2) + cls1 = Class(name="Class1", parent=mod2) + mod2.members.update(Class1=cls1) + meth1 = Function(name="meth1", parent=cls1) + cls1.members.update(meth1=meth1) + + def check_incompat( + parent: Object, + title: str, + ref: str, + *, + expected_reasons: list[str] | None = None, + expected_replacement: str = "", + ) -> IncompatibleRef | None: + """Test incompatible ref detection for a single crossref.""" + crossref = f"[{title}][{ref}]" + doc = Docstring(parent=parent, value=crossref, lineno=1) + incompat: list[IncompatibleRef] = [] + match = _RE_CROSSREF.search(doc.value) + assert match is not None + _RelativeCrossrefProcessor(doc, incompatible_refs=incompat)(match) + if expected_reasons is None: + assert len(incompat) == 0, f"Expected no incompatibilities, got {incompat}" + return None + assert len(incompat) == 1, f"Expected 1 incompatibility, got {len(incompat)}" + result = incompat[0] + for reason in expected_reasons: + assert any(reason in r for r in result.reasons), ( + f"Expected reason containing '{reason}' in {result.reasons}" + ) + if expected_replacement: + assert result.replacement == expected_replacement + return result + + # Standard syntax: no incompatibility + check_incompat(meth1, "foo", "..bar", expected_reasons=None) + check_incompat(cls1, "foo", ".", expected_reasons=None) + check_incompat(meth1, "foo", "..", expected_reasons=None) + check_incompat(meth1, "foo", "...", expected_reasons=None) + + # Caret specifier: incompatible + check_incompat( + meth1, "foo", "^", + expected_reasons=["'^' caret"], + expected_replacement="[foo][..]", + ) + check_incompat( + meth1, "foo", "^.", + expected_reasons=["'^' caret"], + expected_replacement="[foo][..]", + ) + check_incompat( + meth1, "foo", "^.bar", + expected_reasons=["'^' caret"], + expected_replacement="[foo][..bar]", + ) + check_incompat( + meth1, "foo", "^^", + expected_reasons=["'^' caret"], + expected_replacement="[foo][...]", + ) + + # Class specifier: incompatible + check_incompat( + meth1, "foo", "(c)", + expected_reasons=["'(c)' class"], + expected_replacement="[foo][..]", + ) + check_incompat( + meth1, "foo", "(c).", + expected_reasons=["'(c)' class"], + expected_replacement="[foo][..]", + ) + check_incompat( + meth1, "foo", "(C).baz", + expected_reasons=["'(c)' class"], + expected_replacement="[foo][..baz]", + ) + check_incompat( + meth1, "foo", "(C).baz.", + expected_reasons=["'(c)' class", "trailing '.'"], + expected_replacement="[foo][..baz.foo]", + ) + + # Module specifier: incompatible + check_incompat( + meth1, "foo", "(m).", + expected_reasons=["'(m)' module"], + expected_replacement="[foo][...]", + ) + check_incompat( + meth1, "foo", "(m).bar.", + expected_reasons=["'(m)' module", "trailing '.'"], + expected_replacement="[foo][...bar.foo]", + ) + + # Package specifier: incompatible + check_incompat( + meth1, "Class1", "(p).", + expected_reasons=["'(p)' package"], + expected_replacement="[Class1][....]", + ) + check_incompat( + meth1, "Class1", "(p).mod2.", + expected_reasons=["'(p)' package", "trailing '.'"], + expected_replacement="[Class1][....mod2.Class1]", + ) + + # Trailing dot only (no parent specifier): incompatible + check_incompat( + meth1, "foo", "mod3.", + expected_reasons=["trailing '.'"], + expected_replacement="[foo][mod3.foo]", + ) + + # Trailing dot after name with dot-prefix up specifier: incompatible + check_incompat( + meth1, "bar", "..foo.", + expected_reasons=["trailing '.'"], + expected_replacement="[bar][..foo.bar]", + ) + + # Question mark prefix: incompatible + check_incompat( + cls1, "foo", "?.", + expected_reasons=["leading '?'"], + expected_replacement="[foo][.]", + ) + check_incompat( + cls1, "foo", "?mod1.mod2.Class1.foo", + expected_reasons=["leading '?'"], + expected_replacement="[foo][mod1.mod2.Class1.foo]", + ) + check_incompat( + meth1, "foo", "?(m).", + expected_reasons=["'(m)' module", "leading '?'"], + expected_replacement="[foo][...]", + ) + + + +def test_substitute_incompatible_refs() -> None: + """Test incompatible ref collection through substitute_relative_crossrefs.""" + mod1 = Module(name="mod1", filepath=Path("mod1.py")) + cls1 = Class(name="Class1", parent=mod1) + mod1.members["Class1"] = cls1 + meth1 = Function(name="meth1", parent=cls1) + cls1.members["meth1"] = meth1 + + meth1.docstring = Docstring( + "[foo][(c).] [bar][..bar]", + parent=meth1, + lineno=10, + ) + + incompat: list[IncompatibleRef] = [] + substitute_relative_crossrefs(mod1, incompatible_refs=incompat) + + # (c). is incompatible, .. is standard + assert len(incompat) == 1 + assert incompat[0].original == "[foo][(c).]" + assert incompat[0].replacement == "[foo][..]" + assert any("(c)" in r for r in incompat[0].reasons) diff --git a/tests/test_handler.py b/tests/test_handler.py index b9795ab..11211d1 100644 --- a/tests/test_handler.py +++ b/tests/test_handler.py @@ -24,6 +24,7 @@ import pytest from griffe import Docstring, Object, Module +import griffe from mkdocstrings import CollectionError from mkdocstrings_handlers.python import PythonConfig from mkdocstrings_handlers.python import PythonHandler @@ -152,3 +153,98 @@ def fake_render(_self: PythonHandler, data: Object, _config: dict, locale: str|N ) assert rendered == "[foo][bad.foo] [bar][bad.bar]" assert len(caplog.records) == 0 + + # + # Test compatibility_check option + # + + mod_parent = Module(name='pkg', filepath=Path('pkg.py')) + mod_child = Module(name='mod', filepath=Path('mod.py'), parent=mod_parent) + mod_parent.members['mod'] = mod_child + cls_test = griffe.Class(name='Cls', parent=mod_child) + mod_child.members['Cls'] = cls_test + meth_test = griffe.Function(name='meth', parent=cls_test) + cls_test.members['meth'] = meth_test + + docstring = "[foo][(c).] [bar][..bar]" + meth_test.docstring = Docstring(docstring, parent=meth_test) + caplog.clear() + rendered = handler.render( + meth_test, + PythonRelXRefOptions( + relative_crossrefs=True, + check_crossrefs=False, + compatibility_check="warn", + ), # type: ignore[call-arg] + ) + # (c). is incompatible, ..bar is standard + compat_warnings = [r for r in caplog.records if "Incompatible" in r.message] + assert len(compat_warnings) == 1 + assert compat_warnings[0].levelno == logging.WARNING + assert "(c)" in compat_warnings[0].message + caplog.clear() + + # Test error level + meth_test.docstring = Docstring("[foo][(c).]", parent=meth_test) + rendered = handler.render( + meth_test, + PythonRelXRefOptions( + relative_crossrefs=True, + check_crossrefs=False, + compatibility_check="error", + ), # type: ignore[call-arg] + ) + compat_errors = [r for r in caplog.records if "Incompatible" in r.message] + assert len(compat_errors) == 1 + assert compat_errors[0].levelno == logging.ERROR + caplog.clear() + + # + # Test compatibility_patch option + # + + patch_file = os.path.join(tmpdir, "compat.patch") + config2 = PythonConfig( # type: ignore[call-arg] + paths=['path1'], + options={'compatibility_patch': patch_file}, + ) + handler2 = PythonRelXRefHandler( + config2, + Path(tmpdir), + theme='material', + custom_templates='custom_templates', + mdx=[], + mdx_config={}, + ) + + mod_src_path = Path(tmpdir) / 'mod2.py' + mod_source = 'class C:\n def m(self):\n """[bar][(m).]"""\n' + mod_src_path.write_text(mod_source) + + mod2 = Module(name='mod2', filepath=mod_src_path) + cls2 = griffe.Class(name="C", parent=mod2) + mod2.members["C"] = cls2 + meth2 = griffe.Function(name="m", parent=cls2) + cls2.members["m"] = meth2 + meth2.docstring = Docstring("[bar][(m).]", parent=meth2, lineno=3) + + handler2.render( + meth2, + PythonRelXRefOptions( + relative_crossrefs=True, + check_crossrefs=False, + compatibility_patch=patch_file, + ), # type: ignore[call-arg] + ) + + # Trigger patch write + handler2._write_patch_file() + assert os.path.exists(patch_file) + patch_content = Path(patch_file).read_text() + assert "---" in patch_content + assert "+++" in patch_content + + # Test that patch file is removed when no incompatibilities found + handler2._incompatible_refs.clear() + handler2._write_patch_file() + assert not os.path.exists(patch_file) From 45241170f0801c83fc872a0003eac9a03f2f7f62 Mon Sep 17 00:00:00 2001 From: Christopher Barber Date: Sat, 21 Feb 2026 10:20:22 -0500 Subject: [PATCH 3/9] Bump version to 2.1.0, update CHANGELOG and docs - Update VERSION to 2.1.0 - Add CHANGELOG entry for compatibility_check and compatibility_patch features - Document new options in docs/config.md with examples - Add migration section to docs/index.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 7 ++ docs/config.md | 64 ++++++++++++++++++- docs/index.md | 23 +++++++ src/mkdocstrings_handlers/python_xref/VERSION | 2 +- 4 files changed, 94 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 264fce9..de0f2c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ *Note that versions roughly correspond to the version of mkdocstrings-python that they are compatible with.* +## 2.1.0 + +* Added `compatibility_check` option to warn or error on cross-reference syntax that + is not supported by the standard mkdocstrings-python handler (#60) +* Added `compatibility_patch` option to generate a patch file converting incompatible + cross-references to standard form (#60) + ## 2.0.1 * Fix extended template configuration (#56) diff --git a/docs/config.md b/docs/config.md index aadc2da..0c6ecab 100644 --- a/docs/config.md +++ b/docs/config.md @@ -3,7 +3,7 @@ that the handler name should be `python_xref` instead of `python`. Because this handler extends the standard [mkdocstrings-python][] handler, the same options are available. -Additional options are added by this extension. Currently, there are three: +Additional options are added by this extension: * **relative_crossrefs**: `bool` - if set to true enables use of relative path syntax in cross-references. @@ -19,6 +19,18 @@ Additional options are added by this extension. Currently, there are three: libraries which are very expensive to import without having to disable checking for all cross-references. +* **compatibility_check**: `false`, `"warn"`, or `"error"` - when set, reports cross-references + that use syntax not supported by the standard [mkdocstrings-python][] handler. This is + useful when planning to migrate away from this extension. The incompatible syntax elements + are: leading `^`, `(c)`, `(m)`, `(p)` specifiers; trailing `.` after a name (which + appends the title); and leading `?` (which suppresses reference checking). + +* **compatibility_patch**: `false` or a file path string - when set to a file path, generates + a unified diff patch file that converts incompatible cross-references to the standard + dot-prefix form. The patch file is overwritten on each build. If no incompatibilities are + found, any existing patch file is removed. The generated patch can be applied with + `git apply` or `patch -p0`. + !!! Example "mkdocs.yml plugins specifications using this handler" === "Always check" @@ -80,6 +92,56 @@ Additional options are added by this extension. Currently, there are three: check_crossrefs: no ``` +!!! Example "Compatibility checking for migration" + + To check for incompatible syntax before migrating to the standard handler: + + === "Warn on incompatibilities" + + ```yaml + plugins: + - mkdocstrings: + default_handler: python_xref + handlers: + python_xref: + options: + relative_crossrefs: yes + compatibility_check: warn + ``` + + === "Error on incompatibilities" + + ```yaml + plugins: + - mkdocstrings: + default_handler: python_xref + handlers: + python_xref: + options: + relative_crossrefs: yes + compatibility_check: error + ``` + + === "Generate a patch file" + + ```yaml + plugins: + - mkdocstrings: + default_handler: python_xref + handlers: + python_xref: + options: + relative_crossrefs: yes + compatibility_check: warn + compatibility_patch: xref-compat.patch + ``` + + Then apply the patch: + + ```bash + git apply xref-compat.patch + ``` + [mkdocstrings-python]: https://mkdocstrings.github.io/python/ diff --git a/docs/index.md b/docs/index.md index 689719c..8d9e88d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -126,6 +126,29 @@ reference expression with a `?`, for example: This function returns a [Path][?pathlib.] instance. ``` +## Migrating to standard mkdocstrings-python + +If you want to migrate from this extension to the standard [mkdocstrings-python][mkdocstrings_python] +handler, you can use the `compatibility_check` and `compatibility_patch` options to help +identify and convert incompatible cross-reference syntax. + +The following syntax elements are specific to this extension and not supported by the +standard handler: + +| Syntax | Description | Standard equivalent | +|--------|-------------|-------------------| +| `^`, `^^`, ... | Caret parent specifier | `..`, `...`, ... (dot prefix) | +| `(c)` | Class specifier | Equivalent number of leading dots | +| `(m)` | Module specifier | Equivalent number of leading dots | +| `(p)` | Package specifier | Equivalent number of leading dots | +| Trailing `.` after a name | Append title to reference | Expand title inline, e.g. `[foo][bar.]` → `[foo][bar.foo]` | +| Leading `?` | Suppress reference checking | Remove the `?` | + +To check for incompatibilities, set `compatibility_check` to `"warn"` or `"error"` in your +handler options. To generate a patch file that converts all incompatible references to +standard form, set `compatibility_patch` to a file path. See the [configuration](config.md) +page for examples. + [mkdocstrings]: https://mkdocstrings.github.io/ [mkdocstrings_python]: https://mkdocstrings.github.io/python/ [relative-crossref-issue]: https://github.com/mkdocstrings/python/issues/27 diff --git a/src/mkdocstrings_handlers/python_xref/VERSION b/src/mkdocstrings_handlers/python_xref/VERSION index 38f77a6..7ec1d6d 100644 --- a/src/mkdocstrings_handlers/python_xref/VERSION +++ b/src/mkdocstrings_handlers/python_xref/VERSION @@ -1 +1 @@ -2.0.1 +2.1.0 From bfc5c6ccd22c7b315b5d04120f21b46538c9d432 Mon Sep 17 00:00:00 2001 From: Christopher Barber Date: Sat, 21 Feb 2026 10:23:35 -0500 Subject: [PATCH 4/9] Doc tweak --- docs/index.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index 8d9e88d..7969007 100644 --- a/docs/index.md +++ b/docs/index.md @@ -129,7 +129,8 @@ This function returns a [Path][?pathlib.] instance. ## Migrating to standard mkdocstrings-python If you want to migrate from this extension to the standard [mkdocstrings-python][mkdocstrings_python] -handler, you can use the `compatibility_check` and `compatibility_patch` options to help +handler, or just want to maintain compatibility with it, then +you can use the `compatibility_check` and `compatibility_patch` options to help identify and convert incompatible cross-reference syntax. The following syntax elements are specific to this extension and not supported by the From 2a04854a43f83cac36093485149009d67faebc94 Mon Sep 17 00:00:00 2001 From: Christopher Barber Date: Sat, 21 Feb 2026 10:39:03 -0500 Subject: [PATCH 5/9] Setting compatibility_patch now implies compatibility warnings. --- pyproject.toml | 2 +- src/mkdocstrings_handlers/python_xref/handler.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8b7bc75..a0e52f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -364,7 +364,7 @@ cmd = "mike serve -F mkdocs.yml" [tool.pixi.tasks.doc-deploy] env = {VERSION = "$(cat src/mkdocstrings_handlers/python_xref/VERSION)"} description = "Deploy the current version to the gh-pages branch" -cmd = "mike deploy -F mkdocs.yml -u $VERSION latest & mike set-default -u $VERSION latest" +cmd = "mike deploy -F mkdocs.yml -u $VERSION latest && mike set-default latest" [tool.pixi.tasks.show-version] env = {VERSION = "$(cat src/mkdocstrings_handlers/python_xref/VERSION)"} diff --git a/src/mkdocstrings_handlers/python_xref/handler.py b/src/mkdocstrings_handlers/python_xref/handler.py index fed14dd..5bf4253 100644 --- a/src/mkdocstrings_handlers/python_xref/handler.py +++ b/src/mkdocstrings_handlers/python_xref/handler.py @@ -74,6 +74,8 @@ def __init__(self, config: PythonConfig, base_dir: Path, **kwargs: Any) -> None: self.compatibility_patch: str | Literal[False] = ( config.options.pop('compatibility_patch', False) ) + if self.compatibility_patch and not self.compatibility_check: + self.compatibility_check = "warn" self._incompatible_refs: list[IncompatibleRef] = [] super().__init__(config, base_dir, **kwargs) if self.compatibility_patch: From 4484767aafb30ace48215140543c929f657171e4 Mon Sep 17 00:00:00 2001 From: Christopher Barber Date: Sat, 21 Feb 2026 10:39:21 -0500 Subject: [PATCH 6/9] Fix doc-deploy pixi task --- docs/index.md | 3 ++- pixi.lock | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/index.md b/docs/index.md index 7969007..251e7b4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -148,7 +148,8 @@ standard handler: To check for incompatibilities, set `compatibility_check` to `"warn"` or `"error"` in your handler options. To generate a patch file that converts all incompatible references to standard form, set `compatibility_patch` to a file path. See the [configuration](config.md) -page for examples. +page for examples. Setting `compatibility_patch` implies a `compatibility_check` of +`"warn"` if not set explicitly. [mkdocstrings]: https://mkdocstrings.github.io/ [mkdocstrings_python]: https://mkdocstrings.github.io/python/ diff --git a/pixi.lock b/pixi.lock index e832fd2..feb6b3c 100644 --- a/pixi.lock +++ b/pixi.lock @@ -1776,8 +1776,8 @@ packages: timestamp: 1764784251344 - pypi: ./ name: mkdocstrings-python-xref - version: 2.0.1 - sha256: 484186e584e5e1b652524a054fcd232c06789f38ffb62859fece09124c7c732a + version: 2.1.0 + sha256: 769a937a41242fd9af524a74933d244250ac81185862bec1522c307a2576ff02 requires_dist: - griffe>=1.0 - mkdocstrings-python>=2.0 From 23a6cc8b2d00355aa23b35f364112acd3e71c548 Mon Sep 17 00:00:00 2001 From: Christopher Barber Date: Sat, 21 Feb 2026 11:00:45 -0500 Subject: [PATCH 7/9] Fix patch generation to use relative paths git apply rejects absolute paths in unified diffs. Convert filepaths to be relative to the current working directory (project root). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/mkdocstrings_handlers/python_xref/handler.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/mkdocstrings_handlers/python_xref/handler.py b/src/mkdocstrings_handlers/python_xref/handler.py index 5bf4253..810dd1c 100644 --- a/src/mkdocstrings_handlers/python_xref/handler.py +++ b/src/mkdocstrings_handlers/python_xref/handler.py @@ -224,14 +224,22 @@ def _generate_patch(refs: list[IncompatibleRef]) -> str: for ref in refs: by_file[ref.filepath].append(ref) + cwd = Path.cwd() patch_parts: list[str] = [] for filepath, file_refs in sorted(by_file.items()): try: - original_lines = Path(filepath).read_text(encoding="utf-8").splitlines(keepends=True) + abs_path = Path(filepath) + original_lines = abs_path.read_text(encoding="utf-8").splitlines(keepends=True) except OSError: logger.warning("Cannot read file for patch: %s", filepath) continue + # Use relative path for git apply compatibility + try: + rel_path = str(abs_path.relative_to(cwd)) + except ValueError: + rel_path = filepath + modified_lines = list(original_lines) # Sort refs by line (descending) to apply replacements from bottom to top for ref in sorted(file_refs, key=lambda r: (r.line, r.col), reverse=True): @@ -256,8 +264,8 @@ def _generate_patch(refs: list[IncompatibleRef]) -> str: diff = difflib.unified_diff( original_lines, modified_lines, - fromfile=f"a/{filepath}", - tofile=f"b/{filepath}", + fromfile=f"a/{rel_path}", + tofile=f"b/{rel_path}", ) patch_parts.append(''.join(diff)) From 0a54317fe9df2c20453dd1a289d6e0259f6650a1 Mon Sep 17 00:00:00 2001 From: Christopher Barber Date: Sat, 21 Feb 2026 11:23:32 -0500 Subject: [PATCH 8/9] Fix docs: patch -p1, not -p0 The a/ and b/ prefixes require -p1 to strip one path component. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/config.md | 2 +- pixi.lock | 2 +- pyproject.toml | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/config.md b/docs/config.md index 0c6ecab..fd81f71 100644 --- a/docs/config.md +++ b/docs/config.md @@ -29,7 +29,7 @@ Additional options are added by this extension: a unified diff patch file that converts incompatible cross-references to the standard dot-prefix form. The patch file is overwritten on each build. If no incompatibilities are found, any existing patch file is removed. The generated patch can be applied with - `git apply` or `patch -p0`. + `git apply` or `patch -p1`. !!! Example "mkdocs.yml plugins specifications using this handler" diff --git a/pixi.lock b/pixi.lock index feb6b3c..48a0e88 100644 --- a/pixi.lock +++ b/pixi.lock @@ -1777,7 +1777,7 @@ packages: - pypi: ./ name: mkdocstrings-python-xref version: 2.1.0 - sha256: 769a937a41242fd9af524a74933d244250ac81185862bec1522c307a2576ff02 + sha256: ab27caa6e6baf57fd4abde7214ed6b7095cb6396e175e4b226451397c7f845e6 requires_dist: - griffe>=1.0 - mkdocstrings-python>=2.0 diff --git a/pyproject.toml b/pyproject.toml index a0e52f4..5744b04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -311,6 +311,7 @@ env = {VERSION = "$(cat src/mkdocstrings_handlers/python_xref/VERSION)"} cmd = "whl2conda convert dist/mkdocstrings_python_xref-$VERSION-py3-none-any.whl -w dist --overwrite" depends-on = ["build-wheel"] inputs = ["dist/mkdocstrings_python_xref-*-none-any.whl"] +outputs = ["dist/mkdocstrings_python_xref-*-py_0.conda"] # upload tasks [tool.pixi.tasks.check-upload-wheel] From 8500f4a3d3d6cf7c9425bdd5e2a8081a47015666 Mon Sep 17 00:00:00 2001 From: Christopher Barber Date: Sat, 21 Feb 2026 11:25:47 -0500 Subject: [PATCH 9/9] Update doc on applying patches. --- docs/config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config.md b/docs/config.md index fd81f71..d5f18cc 100644 --- a/docs/config.md +++ b/docs/config.md @@ -29,7 +29,7 @@ Additional options are added by this extension: a unified diff patch file that converts incompatible cross-references to the standard dot-prefix form. The patch file is overwritten on each build. If no incompatibilities are found, any existing patch file is removed. The generated patch can be applied with - `git apply` or `patch -p1`. + `git apply ` or `patch -p1 -i `. !!! Example "mkdocs.yml plugins specifications using this handler"