Skip to content

Commit

Permalink
Merge pull request #3205 from mkdocs/getdeps
Browse files Browse the repository at this point in the history
New `get-deps` command: infer PyPI dependencies from mkdocs.yml
  • Loading branch information
oprypin committed Jun 9, 2023
2 parents fdd30c0 + 619f7cf commit f2d14c5
Show file tree
Hide file tree
Showing 14 changed files with 530 additions and 16 deletions.
4 changes: 2 additions & 2 deletions README.md
Expand Up @@ -44,7 +44,7 @@ Make sure to stick around to answer some questions as well!

- [Official Documentation][mkdocs]
- [Latest Release Notes][release-notes]
- [Best-of-MkDocs][best-of] (Third-party themes, recipes, plugins and more)
- [Catalog of third-party plugins, themes and recipes][catalog]

## Contributing to MkDocs

Expand Down Expand Up @@ -72,7 +72,7 @@ discussion forums is expected to follow the [PyPA Code of Conduct].
[release-notes]: https://www.mkdocs.org/about/release-notes/
[Contributing Guide]: https://www.mkdocs.org/about/contributing/
[PyPA Code of Conduct]: https://www.pypa.io/en/latest/code-of-conduct/
[best-of]: https://github.com/mkdocs/best-of-mkdocs
[catalog]: https://github.com/mkdocs/catalog

## License

Expand Down
11 changes: 8 additions & 3 deletions docs/dev-guide/plugins.md
Expand Up @@ -16,8 +16,8 @@ pip install mkdocs-foo-plugin
```

Once a plugin has been successfully installed, it is ready to use. It just needs
to be [enabled](#using-plugins) in the configuration file. The [Best-of-MkDocs]
page has a large list of plugins that you can install and use.
to be [enabled](#using-plugins) in the configuration file. The [Catalog]
repository has a large ranked list of plugins that you can install and use.

## Using Plugins

Expand Down Expand Up @@ -514,6 +514,10 @@ entry_points={
Note that registering a plugin does not activate it. The user still needs to
tell MkDocs to use it via the config.
### Publishing a Plugin
You should publish a package on [PyPI], then add it to the [Catalog] for discoverability. Plugins are strongly recommended to have a unique plugin name (entry point name) according to the catalog.
[BasePlugin]:#baseplugin
[config]: ../user-guide/configuration.md#plugins
[entry point]: #entry-point
Expand All @@ -526,5 +530,6 @@ tell MkDocs to use it via the config.
[post_template]: #on_post_template
[static_templates]: ../user-guide/configuration.md#static_templates
[Template Events]: #template-events
[Best-of-MkDocs]: https://github.com/mkdocs/best-of-mkdocs
[catalog]: https://github.com/mkdocs/catalog
[on_build_error]: #on_build_error
[PyPI]: https://pypi.org/
6 changes: 3 additions & 3 deletions docs/dev-guide/themes.md
Expand Up @@ -6,8 +6,8 @@ A guide to creating and distributing custom themes.

NOTE:
If you are looking for existing third party themes, they are listed in the
[community wiki] page and [Best-of-MkDocs]. If you want to share a theme you create, you
should list it there.
[community wiki] page and the [MkDocs project catalog][catalog]. If you want to
share a theme you create, you should list it there.

When creating a new theme, you can either follow the steps in this guide to
create one from scratch or you can download the `mkdocs-basic-theme` as a
Expand All @@ -16,7 +16,7 @@ this base theme on [GitHub][basic theme]**. It contains detailed comments in
the code to describe the different features and their usage.

[community wiki]: https://github.com/mkdocs/mkdocs/wiki/MkDocs-Themes
[Best-of-MkDocs]: https://github.com/mkdocs/best-of-mkdocs#-theming
[catalog]: https://github.com/mkdocs/catalog#-theming
[basic theme]: https://github.com/mkdocs/mkdocs-basic-theme

## Creating a custom theme
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Expand Up @@ -33,7 +33,7 @@ configuration file. Start by reading the [introductory tutorial], then check the
<a href="user-guide/choosing-your-theme/#readthedocs">readthedocs</a>,
select one of the third-party themes
(on the <a href="https://github.com/mkdocs/mkdocs/wiki/MkDocs-Themes">MkDocs Themes</a> wiki page
as well as <a href="https://github.com/mkdocs/best-of-mkdocs#-theming">Best-of-MkDocs</a>),
as well as the <a href="https://github.com/mkdocs/catalog#-theming">MkDocs Catalog</a>),
or <a href="dev-guide/themes/">build your own</a>.
</p>
</div>
Expand Down
4 changes: 2 additions & 2 deletions docs/user-guide/choosing-your-theme.md
Expand Up @@ -218,7 +218,7 @@ theme supports the following options:

## Third Party Themes

A list of third party themes can be found at the [community wiki] page and [Best-of-MkDocs]. If you have created your own, please add them there.
A list of third party themes can be found at the [community wiki] page and [the ranked catalog][catalog]. If you have created your own, please add them there.

[third party themes]: #third-party-themes
[theme]: configuration.md#theme
Expand All @@ -229,5 +229,5 @@ A list of third party themes can be found at the [community wiki] page and [Best
[upgrade-GA4]: https://support.google.com/analytics/answer/9744165?hl=en&ref_topic=9303319
[Read the Docs]: https://readthedocs.org/
[community wiki]: https://github.com/mkdocs/mkdocs/wiki/MkDocs-Themes
[Best-of-MkDocs]: https://github.com/mkdocs/best-of-mkdocs#-theming
[catalog]: https://github.com/mkdocs/catalog#-theming
[localizing your theme]: localizing-your-theme.md
4 changes: 2 additions & 2 deletions docs/user-guide/configuration.md
Expand Up @@ -575,7 +575,7 @@ This alternative syntax is required if you intend to override some options via
> which are available out-of-the-box. For a list of configuration options
> available for a given extension, see the documentation for that extension.
>
> You may also install and use various third party extensions ([Python-Markdown wiki], [Best-of-MkDocs]). Consult
> You may also install and use various third party extensions ([Python-Markdown wiki], [MkDocs project catalog][catalog]). Consult
> the documentation provided by those extensions for installation instructions
> and available configuration options.
Expand Down Expand Up @@ -964,7 +964,7 @@ path based options in the primary configuration file only.
[smarty]: https://python-markdown.github.io/extensions/smarty/
[exts]: https://python-markdown.github.io/extensions/
[Python-Markdown wiki]: https://github.com/Python-Markdown/markdown/wiki/Third-Party-Extensions
[Best-of-MkDocs]: https://github.com/mkdocs/best-of-mkdocs
[catalog]: https://github.com/mkdocs/catalog
[configuring pages and navigation]: writing-your-docs.md#configure-pages-and-navigation
[theme_dir]: customizing-your-theme.md#using-the-theme_dir
[choosing your theme]: choosing-your-theme.md
Expand Down
29 changes: 28 additions & 1 deletion mkdocs/__main__.py
Expand Up @@ -136,6 +136,9 @@ def __del__(self):
)
shell_help = "Use the shell when invoking Git."
watch_help = "A directory or file to watch for live reloading. Can be supplied multiple times."
projects_file_help = (
"URL or local path of the registry file that declares all known MkDocs-related projects."
)


def add_options(*opts):
Expand Down Expand Up @@ -201,7 +204,7 @@ def callback(ctx, param, value):
PKG_DIR = os.path.dirname(os.path.abspath(__file__))


@click.group(context_settings={'help_option_names': ['-h', '--help']})
@click.group(context_settings=dict(help_option_names=['-h', '--help'], max_content_width=120))
@click.version_option(
__version__,
'-V',
Expand Down Expand Up @@ -287,6 +290,30 @@ def gh_deploy_command(
)


@cli.command(name="get-deps")
@verbose_option
@click.option('-f', '--config-file', type=click.File('rb'), help=config_help)
@click.option(
'-p',
'--projects-file',
default='https://raw.githubusercontent.com/mkdocs/catalog/main/projects.yaml',
help=projects_file_help,
show_default=True,
)
def get_deps_command(config_file, projects_file):
"""Show required PyPI packages inferred from plugins in mkdocs.yml"""
from mkdocs.commands import get_deps

warning_counter = utils.CountHandler()
warning_counter.setLevel(logging.WARNING)
logging.getLogger('mkdocs').addHandler(warning_counter)

get_deps.get_deps(projects_file_url=projects_file, config_file_path=config_file)

if warning_counter.get_counts():
sys.exit(1)


@cli.command(name="new")
@click.argument("project_directory")
@common_options
Expand Down
171 changes: 171 additions & 0 deletions mkdocs/commands/get_deps.py
@@ -0,0 +1,171 @@
from __future__ import annotations

import dataclasses
import datetime
import functools
import logging
import sys
from typing import Mapping, Sequence

if sys.version_info >= (3, 10):
from importlib.metadata import EntryPoint, entry_points
else:
from importlib_metadata import EntryPoint, entry_points

import yaml

from mkdocs import utils
from mkdocs.config.base import _open_config_file
from mkdocs.utils.cache import download_and_cache_url

log = logging.getLogger(__name__)

# Note: do not rely on functions in this module, it is not public API.


class YamlLoader(yaml.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_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
)

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()
def _entry_points(group: str) -> Mapping[str, EntryPoint]:
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_deps(projects_file_url: str, config_file_path: str | None = None) -> None:
"""
Print PyPI package dependencies inferred from a mkdocs.yml file based on a reverse mapping of known projects.
Parameters:
projects_file_url: URL or local path of the registry file that declares all known MkDocs-related projects.
The file is in YAML format and contains `projects: [{mkdocs_theme:, mkdocs_plugin:, markdown_extension:}]
config_file_path: Non-default path to mkdocs.yml.
"""
with _open_config_file(config_file_path) as f:
cfg = utils.yaml_load(f, loader=YamlLoader) # type: ignore

if all(c not in cfg for c in ('site_name', 'theme', 'plugins', 'markdown_extensions')):
log.warning("The passed config file doesn't seem to be a mkdocs.yml config file")

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 - {'mkdocs', 'readthedocs'}),
(PluginKind('mkdocs_plugin', 'mkdocs.plugins'), plugins - {'search'}),
(PluginKind('markdown_extension', 'markdown.extensions'), extensions),
)
for kind, wanted in wanted_plugins:
log.debug(f'Wanted {kind}s: {sorted(wanted)}')

content = download_and_cache_url(projects_file_url, datetime.timedelta(days=7))
projects = yaml.safe_load(content)['projects']

packages_to_install = set()
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
if dist_name not in ('mkdocs', 'Markdown'):
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)

for pkg in sorted(packages_to_install):
print(pkg)

0 comments on commit f2d14c5

Please sign in to comment.