File: __init__.py

package info (click to toggle)
mkdocs-get-deps 0.2.0-2
  • links: PTS, VCS
  • area: main
  • in suites: trixie
  • size: 136 kB
  • sloc: python: 456; sh: 17; makefile: 3
file content (211 lines) | stat: -rw-r--r-- 7,450 bytes parent folder | download | duplicates (2)
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)