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 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 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)
|