This commit is contained in:
CreaRobotics committed 2026-03-02 11:34:40 +01:00
1 parent 6ccab0347b
commit 954a2fd6d6
18684 files changed
+601012 -190

No files matched your search

@@ -0,0 +1,211 @@
from __future__ import annotations
__version__ = "0.2.0"
import dataclasses
import datetime
import functools
import io
import logging
import os
import sys
import urllib.parse
from typing import IO, Any, BinaryIO, Collection, Mapping, Sequence
import yaml
from . import cache, yaml_util
log = logging.getLogger(f"mkdocs.{__name__}")
DEFAULT_PROJECTS_FILE = "https://raw.githubusercontent.com/mkdocs/catalog/main/projects.yaml"
BUILTIN_THEMES = {"mkdocs", "readthedocs"}
BUILTIN_PLUGINS = {"search"}
_BUILTIN_EXTENSIONS = "abbr admonition attr_list codehilite def_list extra fenced_code footnotes md_in_html meta nl2br sane_lists smarty tables toc wikilinks legacy_attrs legacy_em".split()
BUILTIN_EXTENSIONS = {
*_BUILTIN_EXTENSIONS,
*(f"markdown.extensions.{e}" for e in _BUILTIN_EXTENSIONS),
}
_NotFound = ()
def _dig(cfg, keys: str):
"""
Receives a string such as 'foo.bar' and returns `cfg['foo']['bar']`, or `_NotFound`.
A list of single-item dicts gets converted to a flat dict. This is intended for `plugins` config.
"""
key, _, rest = keys.partition(".")
try:
cfg = cfg[key]
except (KeyError, TypeError):
return _NotFound
if isinstance(cfg, list):
orig_cfg = cfg
cfg = {}
for item in reversed(orig_cfg):
if isinstance(item, dict) and len(item) == 1:
cfg.update(item)
elif isinstance(item, str):
cfg[item] = {}
if not rest:
return cfg
return _dig(cfg, rest)
def _strings(obj) -> Sequence[str]:
if isinstance(obj, str):
return (obj,)
else:
return tuple(obj)
@functools.lru_cache(maxsize=None)
def _entry_points(group: str) -> Mapping[str, Any]:
if sys.version_info >= (3, 10):
from importlib.metadata import entry_points
else:
from importlib_metadata import entry_points
eps = {ep.name: ep for ep in entry_points(group=group)}
log.debug(f"Available '{group}' entry points: {sorted(eps)}")
return eps
@dataclasses.dataclass(frozen=True)
class _PluginKind:
projects_key: str
entry_points_key: str
def __str__(self) -> str:
return self.projects_key.rpartition("_")[-1]
def get_projects_file(path: str | None = None) -> BinaryIO:
if path is None:
path = DEFAULT_PROJECTS_FILE
if urllib.parse.urlsplit(path).scheme in ("http", "https"):
content = cache.download_and_cache_url(path, datetime.timedelta(days=1))
else:
with open(path, "rb") as f:
content = f.read()
return io.BytesIO(content)
def get_deps(
config_file: IO | os.PathLike | str | None = None,
projects_file: IO | None = None,
) -> Collection[str]:
"""
Print PyPI package dependencies inferred from a mkdocs.yml file based on a reverse mapping of known projects.
Args:
config_file: Non-default mkdocs.yml file - content as a buffer, or path.
projects_file: File/buffer that declares all known MkDocs-related projects.
The file is in YAML format and contains `projects: [{mkdocs_theme:, mkdocs_plugin:, markdown_extension:}]
"""
if config_file is None:
if os.path.isfile("mkdocs.yml"):
config_file = "mkdocs.yml"
elif os.path.isfile("mkdocs.yaml"):
config_file = "mkdocs.yaml"
else:
config_file = "mkdocs.yml"
opened_config_file: IO
if isinstance(config_file, (str, os.PathLike)):
config_file = os.path.abspath(config_file)
opened_config_file = open(config_file, "rb")
else:
opened_config_file = config_file
log.debug(f"Loading configuration file: {config_file}")
with opened_config_file:
cfg = yaml_util.yaml_load(opened_config_file)
if not isinstance(cfg, dict):
raise ValueError(
f"The configuration is invalid. Expected a key-value mapping but received {type(cfg)}"
)
packages_to_install = set()
if all(c not in cfg for c in ("site_name", "theme", "plugins", "markdown_extensions")):
log.warning(f"The file {config_file!r} doesn't seem to be a mkdocs.yml config file")
else:
if _dig(cfg, "theme.locale") not in (_NotFound, "en"):
packages_to_install.add("mkdocs[i18n]")
else:
packages_to_install.add("mkdocs")
try:
theme = cfg["theme"]["name"]
except (KeyError, TypeError):
theme = cfg.get("theme")
themes = {theme} if theme else set()
plugins = set(_strings(_dig(cfg, "plugins")))
extensions = set(_strings(_dig(cfg, "markdown_extensions")))
wanted_plugins = (
(_PluginKind("mkdocs_theme", "mkdocs.themes"), themes - BUILTIN_THEMES),
(_PluginKind("mkdocs_plugin", "mkdocs.plugins"), plugins - BUILTIN_PLUGINS),
(_PluginKind("markdown_extension", "markdown.extensions"), extensions - BUILTIN_EXTENSIONS),
)
for kind, wanted in wanted_plugins:
log.debug(f"Wanted {kind}s: {sorted(wanted)}")
if projects_file is None:
projects_file = get_projects_file()
with projects_file:
projects = yaml.load(projects_file, Loader=yaml_util.SafeLoader)["projects"]
for project in projects:
for kind, wanted in wanted_plugins:
available = _strings(project.get(kind.projects_key, ()))
for entry_name in available:
if ( # Also check theme-namespaced plugin names against the current theme.
"/" in entry_name
and theme is not None
and kind.projects_key == "mkdocs_plugin"
and entry_name.startswith(f"{theme}/")
and entry_name[len(theme) + 1 :] in wanted
and entry_name not in wanted
):
entry_name = entry_name[len(theme) + 1 :]
if entry_name in wanted:
if "pypi_id" in project:
install_name = project["pypi_id"]
elif "github_id" in project:
install_name = "git+https://github.com/{github_id}".format_map(project)
else:
log.error(
f"Can't find how to install {kind} '{entry_name}' although it was identified as {project}"
)
continue
packages_to_install.add(install_name)
for extra_key, extra_pkgs in project.get("extra_dependencies", {}).items():
if _dig(cfg, extra_key) is not _NotFound:
packages_to_install.update(_strings(extra_pkgs))
wanted.remove(entry_name)
for kind, wanted in wanted_plugins:
for entry_name in sorted(wanted):
dist_name = None
ep = _entry_points(kind.entry_points_key).get(entry_name)
if ep is not None and ep.dist is not None:
dist_name = ep.dist.name
warning = (
f"{str(kind).capitalize()} '{entry_name}' is not provided by any registered project"
)
if ep is not None:
warning += " but is installed locally"
if dist_name:
warning += f" from '{dist_name}'"
log.info(warning)
else:
log.warning(warning)
return sorted(packages_to_install)
@@ -0,0 +1,61 @@
from __future__ import annotations
import argparse
import logging
import sys
from . import get_deps, get_projects_file
parser = argparse.ArgumentParser(
description="Show required PyPI packages inferred from plugins in mkdocs.yml."
)
parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose output")
parser.add_argument(
"-f",
"--config-file",
type=argparse.FileType("r"),
help="Provide a specific MkDocs config. This can be a file name, or '-' to read from stdin.",
default=None,
)
parser.add_argument(
"-p",
"--projects-file",
help="URL or local path of the registry file that declares all known MkDocs-related projects.",
default="https://raw.githubusercontent.com/mkdocs/catalog/main/projects.yaml",
)
class CountHandler(logging.NullHandler):
warning_count = 0
def handle(self, record):
rv = self.filter(record)
if rv:
self.warning_count += 1
return rv
def cli():
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(levelname)s - mkdocs-get-deps: %(message)s",
)
warning_counter = CountHandler()
warning_counter.setLevel(logging.WARNING)
logging.getLogger("mkdocs").addHandler(warning_counter)
with get_projects_file(args.projects_file) as projects_file:
deps = get_deps(config_file=args.config_file, projects_file=projects_file)
for dep in deps:
print(dep) # noqa: T201
if warning_counter.warning_count:
sys.exit(1)
if __name__ == "__main__":
cli()
@@ -0,0 +1,70 @@
from __future__ import annotations
import datetime
import hashlib
import logging
import os
import random
import urllib.request
from typing import Callable
import platformdirs
from . import __version__
log = logging.getLogger(f"mkdocs.{__name__}")
def _download_url(url: str) -> bytes:
req = urllib.request.Request(url, headers={"User-Agent": f"mkdocs-get-deps/{__version__}"})
with urllib.request.urlopen(req) as resp:
return resp.read()
def download_and_cache_url(
url: str,
cache_duration: datetime.timedelta,
*,
download: Callable[[str], bytes] = _download_url,
comment: bytes = b"# ",
) -> bytes:
"""Downloads a file from the URL, stores it under ~/.cache/, and returns its content.
For tracking the age of the content, a prefix is inserted into the stored file, rather than relying on mtime.
Args:
url: URL to use.
download: Callback that will accept the URL and actually perform the download.
cache_duration: How long to consider the URL content cached.
comment: The appropriate comment prefix for this file format.
"""
directory = os.path.join(platformdirs.user_cache_dir("mkdocs"), "mkdocs_url_cache")
name_hash = hashlib.sha256(url.encode()).hexdigest()[:32]
path = os.path.join(directory, name_hash + os.path.splitext(url)[1])
now = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
prefix = b"%s%s downloaded at timestamp " % (comment, url.encode())
# Check for cached file and try to return it
if os.path.isfile(path):
try:
with open(path, "rb") as f:
line = f.readline()
if line.startswith(prefix):
line = line[len(prefix) :]
timestamp = int(line)
if datetime.timedelta(seconds=(now - timestamp)) <= cache_duration:
log.debug(f"Using cached '{path}' for '{url}'")
return f.read()
except (OSError, ValueError) as e:
log.debug(f"{type(e).__name__}: {e}")
# Download and cache the file
log.debug(f"Downloading '{url}' to '{path}'")
content = download(url)
os.makedirs(directory, exist_ok=True)
temp_filename = f"{path}.{random.randrange(1 << 32):08x}.part"
with open(temp_filename, "wb") as f:
f.write(b"%s%d\n" % (prefix, now))
f.write(content)
os.replace(temp_filename, path)
return content
Whitespace-only changes.
@@ -0,0 +1,50 @@
from __future__ import annotations
import logging
import os
import os.path
from typing import IO, Any
import mergedeep # type: ignore
import yaml
try:
from yaml import CSafeLoader as SafeLoader
except ImportError:
from yaml import SafeLoader # type: ignore
log = logging.getLogger(f"mkdocs.{__name__}")
class YamlLoader(SafeLoader):
pass
# Prevent errors from trying to access external modules which may not be installed yet.
YamlLoader.add_constructor("!ENV", lambda loader, node: None) # type: ignore
YamlLoader.add_constructor("!relative", lambda loader, node: None) # type: ignore
YamlLoader.add_multi_constructor(
"tag:yaml.org,2002:python/name:", lambda loader, suffix, node: None
)
YamlLoader.add_multi_constructor(
"tag:yaml.org,2002:python/object/apply:", lambda loader, suffix, node: None
)
def yaml_load(source: IO | str) -> dict[str, Any]:
"""Return dict of source YAML file using loader, recursively deep merging inherited parent."""
result = yaml.load(source, Loader=YamlLoader)
if result is None:
return {}
if (
"INHERIT" in result
and not isinstance(source, str)
and getattr(source, "name", None) is not None
):
relpath = result.pop("INHERIT")
abspath = os.path.normpath(os.path.join(os.path.dirname(source.name), relpath))
log.debug(f"Loading inherited configuration file: {abspath}")
with open(abspath, "rb") as f:
parent = yaml_load(f)
result = mergedeep.merge(parent, result)
return result