Skip to content

Commit 12f9477

Browse files
authored
Merge pull request #26 from ProperDocs/getdeps
Integrate the "mkdocs-get-deps" codebase directly
2 parents 4e039a8 + 50cec63 commit 12f9477

File tree

7 files changed

+485
-11
lines changed

7 files changed

+485
-11
lines changed

properdocs/__main__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -344,8 +344,7 @@ def gh_deploy_command(
344344
)
345345
def get_deps_command(config_file, projects_file):
346346
"""Show required PyPI packages inferred from plugins in mkdocs.yml."""
347-
from mkdocs_get_deps import get_deps, get_projects_file
348-
347+
from properdocs.commands.get_deps import get_deps, get_projects_file
349348
from properdocs.config.base import _open_config_file
350349

351350
warning_counter = utils.CountHandler()

properdocs/commands/get_deps.py

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
# Copyright (c) 2023 Oleh Prypin <oleh@pryp.in>
2+
3+
from __future__ import annotations
4+
5+
import dataclasses
6+
import datetime
7+
import functools
8+
import io
9+
import logging
10+
import os
11+
import sys
12+
import urllib.parse
13+
from collections.abc import Collection, Mapping, Sequence
14+
from typing import IO, Any, BinaryIO
15+
16+
import yaml
17+
18+
from properdocs.config.base import _open_config_file
19+
from properdocs.utils import cache
20+
from properdocs.utils import yaml as yaml_util
21+
22+
SafeLoader: type[yaml.SafeLoader | yaml.CSafeLoader]
23+
try:
24+
from yaml import CSafeLoader as SafeLoader
25+
except ImportError:
26+
from yaml import SafeLoader
27+
28+
log = logging.getLogger(__name__)
29+
30+
31+
class YamlLoaderWithSuppressions(SafeLoader): # type: ignore
32+
pass
33+
34+
35+
# Prevent errors from trying to access external modules which may not be installed yet.
36+
YamlLoaderWithSuppressions.add_constructor("!ENV", lambda loader, node: None)
37+
YamlLoaderWithSuppressions.add_constructor("!relative", lambda loader, node: None)
38+
YamlLoaderWithSuppressions.add_multi_constructor(
39+
"tag:yaml.org,2002:python/name:", lambda loader, suffix, node: None
40+
)
41+
YamlLoaderWithSuppressions.add_multi_constructor(
42+
"tag:yaml.org,2002:python/object/apply:", lambda loader, suffix, node: None
43+
)
44+
45+
46+
DEFAULT_PROJECTS_FILE = "https://raw.githubusercontent.com/properdocs/catalog/main/projects.yaml"
47+
48+
BUILTIN_PLUGINS = {"search"}
49+
_BUILTIN_EXTENSIONS = [
50+
"abbr",
51+
"admonition",
52+
"attr_list",
53+
"codehilite",
54+
"def_list",
55+
"extra",
56+
"fenced_code",
57+
"footnotes",
58+
"md_in_html",
59+
"meta",
60+
"nl2br",
61+
"sane_lists",
62+
"smarty",
63+
"tables",
64+
"toc",
65+
"wikilinks",
66+
"legacy_attrs",
67+
"legacy_em",
68+
]
69+
BUILTIN_EXTENSIONS = {
70+
*_BUILTIN_EXTENSIONS,
71+
*(f"markdown.extensions.{e}" for e in _BUILTIN_EXTENSIONS),
72+
}
73+
74+
_NotFound = ()
75+
76+
77+
def _dig(cfg, keys: str):
78+
"""
79+
Receives a string such as 'foo.bar' and returns `cfg['foo']['bar']`, or `_NotFound`.
80+
81+
A list of single-item dicts gets converted to a flat dict. This is intended for `plugins` config.
82+
"""
83+
key, _, rest = keys.partition(".")
84+
try:
85+
cfg = cfg[key]
86+
except (KeyError, TypeError):
87+
return _NotFound
88+
if isinstance(cfg, list):
89+
orig_cfg = cfg
90+
cfg = {}
91+
for item in reversed(orig_cfg):
92+
if isinstance(item, dict) and len(item) == 1:
93+
cfg.update(item)
94+
elif isinstance(item, str):
95+
cfg[item] = {}
96+
if not rest:
97+
return cfg
98+
return _dig(cfg, rest)
99+
100+
101+
def _strings(obj) -> Sequence[str]:
102+
if isinstance(obj, str):
103+
return (obj,)
104+
else:
105+
return tuple(obj)
106+
107+
108+
@functools.cache
109+
def _entry_points(group: str) -> Mapping[str, Any]:
110+
if sys.version_info >= (3, 10):
111+
from importlib.metadata import entry_points
112+
else:
113+
from importlib_metadata import entry_points
114+
115+
eps = {ep.name: ep for ep in entry_points(group=group)}
116+
log.debug(f"Available '{group}' entry points: {sorted(eps)}")
117+
return eps
118+
119+
120+
@dataclasses.dataclass(frozen=True)
121+
class _PluginKind:
122+
projects_key: str
123+
entry_points_key: str
124+
125+
def __str__(self) -> str:
126+
return self.projects_key.rpartition("_")[-1]
127+
128+
129+
def get_projects_file(path: str | None = None) -> BinaryIO:
130+
if path is None:
131+
path = DEFAULT_PROJECTS_FILE
132+
if urllib.parse.urlsplit(path).scheme in ("http", "https"):
133+
content = cache.download_and_cache_url(path, datetime.timedelta(days=1))
134+
else:
135+
with open(path, "rb") as f:
136+
content = f.read()
137+
return io.BytesIO(content)
138+
139+
140+
def get_deps(
141+
config_file: IO | os.PathLike | str | None = None,
142+
projects_file: IO | None = None,
143+
) -> Collection[str]:
144+
"""
145+
Print PyPI package dependencies inferred from a mkdocs.yml file based on a reverse mapping of known projects.
146+
147+
Args:
148+
config_file: Non-default mkdocs.yml file - content as a buffer, or path.
149+
projects_file: File/buffer that declares all known ProperDocs-related projects.
150+
The file is in YAML format and contains `projects: [{mkdocs_theme:, mkdocs_plugin:, markdown_extension:}]
151+
"""
152+
if isinstance(config_file, (str, os.PathLike)):
153+
config_file = os.path.abspath(config_file)
154+
with _open_config_file(config_file) as opened_config_file:
155+
cfg = yaml_util.yaml_load(opened_config_file, loader=YamlLoaderWithSuppressions)
156+
if not isinstance(cfg, dict):
157+
raise ValueError(
158+
f"The configuration is invalid. Expected a key-value mapping but received {type(cfg)}"
159+
)
160+
161+
packages_to_install = set()
162+
163+
if all(c not in cfg for c in ("site_name", "theme", "plugins", "markdown_extensions")):
164+
log.warning(f"The file {config_file!r} doesn't seem to be a mkdocs.yml config file")
165+
else:
166+
if _dig(cfg, "theme.locale") not in (_NotFound, "en"):
167+
packages_to_install.add("properdocs[i18n]")
168+
else:
169+
packages_to_install.add("properdocs")
170+
171+
try:
172+
theme = cfg["theme"]["name"]
173+
except (KeyError, TypeError):
174+
theme = cfg.get("theme")
175+
themes = {theme} if theme else set()
176+
177+
plugins = set(_strings(_dig(cfg, "plugins"))) - BUILTIN_PLUGINS
178+
extensions = set(_strings(_dig(cfg, "markdown_extensions"))) - BUILTIN_EXTENSIONS
179+
180+
wanted_plugins = (
181+
(_PluginKind("properdocs_theme", "properdocs.themes"), themes),
182+
(_PluginKind("mkdocs_theme", "mkdocs.themes"), themes),
183+
(_PluginKind("properdocs_plugin", "properdocs.plugins"), plugins),
184+
(_PluginKind("mkdocs_plugin", "mkdocs.plugins"), plugins),
185+
(_PluginKind("markdown_extension", "markdown.extensions"), extensions),
186+
)
187+
for kind, wanted in (wanted_plugins[0], wanted_plugins[2], wanted_plugins[4]):
188+
log.debug(f"Wanted {kind}s: {sorted(wanted)}")
189+
190+
if projects_file is None:
191+
projects_file = get_projects_file()
192+
with projects_file:
193+
projects = yaml.load(projects_file, Loader=SafeLoader)["projects"]
194+
195+
for project in projects:
196+
for kind, wanted in wanted_plugins:
197+
available = _strings(project.get(kind.projects_key, ()))
198+
for entry_name in available:
199+
if ( # Also check theme-namespaced plugin names against the current theme.
200+
"/" in entry_name
201+
and theme is not None
202+
and kind.projects_key in ("properdocs_plugin", "mkdocs_plugin")
203+
and entry_name.startswith(f"{theme}/")
204+
and entry_name[len(theme) + 1 :] in wanted
205+
and entry_name not in wanted
206+
):
207+
entry_name = entry_name[len(theme) + 1 :]
208+
if entry_name in wanted:
209+
if "pypi_id" in project:
210+
install_name = project["pypi_id"]
211+
elif "github_id" in project:
212+
install_name = "git+https://github.com/{github_id}".format_map(project)
213+
else:
214+
log.error(
215+
f"Can't find how to install {kind} '{entry_name}' although it was identified as {project}"
216+
)
217+
continue
218+
packages_to_install.add(install_name)
219+
for extra_key, extra_pkgs in project.get("extra_dependencies", {}).items():
220+
if _dig(cfg, extra_key) is not _NotFound:
221+
packages_to_install.update(_strings(extra_pkgs))
222+
223+
wanted.remove(entry_name)
224+
225+
warnings: dict[str, str] = {}
226+
227+
for kind, wanted in wanted_plugins:
228+
for entry_name in sorted(wanted):
229+
dist_name = None
230+
ep = _entry_points(kind.entry_points_key).get(entry_name)
231+
if ep is not None and ep.dist is not None:
232+
dist_name = ep.dist.name
233+
base_warning = (
234+
f"{str(kind).capitalize()} '{entry_name}' is not provided by any registered project"
235+
)
236+
if ep is not None:
237+
warning = base_warning + " but is installed locally"
238+
if dist_name:
239+
warning += f" from '{dist_name}'"
240+
warnings[base_warning] = warning # Always prefer the lesser warning
241+
else:
242+
warnings.setdefault(base_warning, base_warning)
243+
244+
for warning in warnings.values():
245+
if " is installed " in warning:
246+
log.info(warning)
247+
else:
248+
log.warning(warning)
249+
250+
return sorted(packages_to_install)

0 commit comments

Comments
 (0)