doc/.venv/Lib/site-packages/mkdocs_get_deps/__init__.py

212 lines
7.3 KiB
Python

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)