diff --git a/python/zensical/config.py b/python/zensical/config.py index e50fa7e..0f78430 100644 --- a/python/zensical/config.py +++ b/python/zensical/config.py @@ -163,6 +163,101 @@ def get_config() -> dict: return _CONFIG # ty:ignore[invalid-return-type] +def _yaml_load(source: IO) -> dict[str, Any]: + """Load configuration file, resolve environment variables and parent files. + + Note that INHERIT is only a bandaid that was introduced to allow for some + degree of modularity, but with serious shortcomings. Zensical will use a + different approach in the future, which will allow for composable and + environment-specific configuration. + """ + Loader.add_constructor("!ENV", _construct_env_tag) + try: + config = yaml.load( + # Compatibility shim: we remap Material's extension namespace to + # Zensical's, and the now deprecated materialx namespace as well + source.read() + .replace("material.extensions", "zensical.extensions") + .replace("materialx", "zensical.extensions"), + Loader=Loader, # noqa: S506 + ) + except YAMLError as e: + raise ConfigurationError( + f"Encountered an error parsing the configuration file: {e}" + ) from e + if config is None: + return {} + + # Try to resolve inherited configuration file + if "INHERIT" in config and not isinstance(source, str): + relpath = config.pop("INHERIT") + abspath = os.path.normpath( + os.path.join(os.path.dirname(source.name), relpath) + ) + if not os.path.exists(abspath): + raise ConfigurationError( + f"Inherited config file '{relpath}' " + f"doesn't exist at '{abspath}'." + ) + with open(abspath, encoding="utf-8") as fd: + parent = _yaml_load(fd) + config = always_merger.merge(parent, config) + + # Return resulting configuration + return config + + +def _construct_env_tag( + loader: yaml.Loader, + node: yaml.ScalarNode | yaml.SequenceNode | yaml.MappingNode, +) -> Any: + """Assign value of ENV variable referenced at node. + + MkDocs supports the use of !ENV to reference environment variables in YAML + configuration files. We won't likely support this in Zensical, but for now + we need it to build MkDocs projects. Zensical will use a different approach + to create environment-specific configuration in the future. + + Licensed under MIT + Copyright (c) 2020 Waylan Limberg + Taken and adapted from + https://github.com/waylan/pyyaml-env-tag/blob/master/yaml_env_tag.py + """ + default = None + + # Handle !ENV + if isinstance(node, yaml.nodes.ScalarNode): + vars = [loader.construct_scalar(node)] + + # Handle !ENV [, ] + elif isinstance(node, yaml.nodes.SequenceNode): + child_nodes = node.value + if len(child_nodes) > 1: + default = loader.construct_object(child_nodes[-1]) + child_nodes = child_nodes[:-1] + # Env Vars are resolved as string values, ignoring (implicit) types. + vars = [loader.construct_scalar(child) for child in child_nodes] + else: + raise ConstructorError( + context=f"expected a scalar or sequence node, but found {node.id}", + context_mark=node.start_mark, + ) + + # Resolve environment variable + for var in vars: + if var in os.environ: + value = os.environ[var] + # Resolve value to Python type using YAML's implicit resolvers + tag = loader.resolve(yaml.nodes.ScalarNode, value, (True, False)) + return loader.construct_object(yaml.nodes.ScalarNode(tag, value)) + + # Otherwise return default + return default + + +# ---------------------------------------------------------------------------- + + @functools.cache def get_themes() -> dict[str, EntryPoint]: """Return available themes. @@ -218,6 +313,49 @@ def get_custom_theme_dir(path: str, config_path: str) -> str: return theme_dir +def _yaml_load_theme_config(source: IO) -> dict[str, Any]: + Loader.add_constructor("!ENV", _construct_env_tag) + try: + config = yaml.load(source, Loader=Loader) # noqa: S506 + except YAMLError as e: + raise ConfigurationError( + f"Encountered an error parsing the theme configuration file: {e}" + ) from e + if config is None: + return {} + return config + + +def _load_theme_config(theme_dir: str) -> tuple[dict[str, Any], list[str]]: + # Keep track of the theme directories, + # so that we can pass them up to Minijinja. + theme_dirs = [theme_dir] + + if (theme_config_file := Path(theme_dir, "mkdocs_theme.yml")).is_file(): + # We found a theme configuration file (mkdocs_theme.yml). + with theme_config_file.open(encoding="utf-8") as file: + theme_config = _yaml_load_theme_config(file) + if extends := theme_config.pop("extends", None): + # This theme extends another theme, + # so we load this parent theme's configuration (recursively) + # and merge the current theme's configuration into it. + parent_theme_dir = get_theme_dir(extends) + parent_config, parent_dirs = _load_theme_config( + parent_theme_dir + ) + parent_config.update(theme_config) + theme_dirs.extend(parent_dirs) + return parent_config, theme_dirs + # Otherwise we just return the current theme configuration. + return theme_config, theme_dirs + + # No theme configuration. + return {}, theme_dirs + + +# ---------------------------------------------------------------------------- + + def _apply_defaults(config: dict, path: str) -> dict: """Apply default settings in configuration. @@ -506,56 +644,11 @@ def _apply_defaults(config: dict, path: str) -> dict: # to support the same for when we load TOML. This is a bandaid, and we will # find a better solution, once we work on configuration management, but for # now this should be sufficient. - emoji = config["mdx_configs"].get("pymdownx.emoji", {}) - if isinstance(emoji.get("emoji_generator"), str): - emoji["emoji_generator"] = _resolve(emoji.get("emoji_generator")) - if isinstance(emoji.get("emoji_index"), str): - emoji["emoji_index"] = _resolve(emoji.get("emoji_index")) - - # Tabbed extension configuration - resolve slugification function - tabbed = config["mdx_configs"].get("pymdownx.tabbed", {}) - if isinstance(tabbed.get("slugify"), dict): - object = tabbed["slugify"].get("object", "pymdownx.slugs.slugify") - tabbed["slugify"] = _resolve(object)( - **tabbed["slugify"].get("kwds", {}) - ) - - # Blocks-tab extension configuration - resolve slugification function - blocks_tab = config["mdx_configs"].get("pymdownx.blocks.tab", {}) - if isinstance(blocks_tab.get("slugify"), dict): - object = blocks_tab["slugify"].get("object", "pymdownx.slugs.slugify") - blocks_tab["slugify"] = _resolve(object)( - **blocks_tab["slugify"].get("kwds", {}) - ) - - # Table of contents extension configuration - resolve slugification function - toc = config["mdx_configs"]["toc"] - if isinstance(toc.get("slugify"), dict): - object = toc["slugify"].get("object", "pymdownx.slugs.slugify") - toc["slugify"] = _resolve(object)(**toc["slugify"].get("kwds", {})) - - # Superfences extension configuration - resolve format function - superfences = config["mdx_configs"].get("pymdownx.superfences", {}) - for fence in superfences.get("custom_fences", []): - if isinstance(fence.get("format"), str): - fence["format"] = _resolve(fence.get("format")) - elif isinstance(fence.get("format"), dict): - object = fence["format"].get( - "object", "pymdownx.superfences.fence_code_format" - ) - fence["format"] = _resolve(object)( - **fence["format"].get("kwds", {}) - ) - if isinstance(fence.get("validator"), str): - fence["validator"] = _resolve(fence.get("validator")) - elif isinstance(fence.get("validator"), dict): - object = fence["validator"].get("object") - callable_object = ( - _resolve(object) if object else lambda *args, **kwargs: True - ) - fence["validator"] = callable_object( - **fence["validator"].get("kwds", {}) - ) + _resolve_pymdownx_emoji(config) + _resolve_pymdownx_superfences(config) + _resolve_pymdownx_tabbed(config) + _resolve_pymdownx_blocks_tab(config) + _resolve_toc(config) # Ensure the table of contents title is initialized, as it's used inside # the template, and the table of contents extension is always defined @@ -565,43 +658,10 @@ def _apply_defaults(config: dict, path: str) -> dict: # Convert plugins configuration config["plugins"] = _convert_plugins(config.get("plugins", []), config) - # Set up mkdocstrings, which touches plugins and Markdown extensions - if "mkdocstrings" in config["plugins"]: - mkdocstrings_config = config["plugins"]["mkdocstrings"]["config"] - if mkdocstrings_config.get("enabled", True): - if not find_spec("mkdocstrings"): - raise ConfigurationError( - "mkdocstrings plugin is enabled, but mkdocstrings is not " - "installed. Please install mkdocstrings or disable the " - "plugin." - ) - if autorefs := get_autorefs_extension(): - config["markdown_extensions"].append(autorefs) - mkdocstrings = get_mkdocstrings_extension(config, path) - config["markdown_extensions"].append(mkdocstrings) - - # Support autorefs in standalone mode - elif "autorefs" in config["plugins"]: - if autorefs := get_autorefs_extension(): - config["markdown_extensions"].append(autorefs) - else: - raise ConfigurationError( - "autorefs plugin is enabled, but autorefs is not " - "installed. Please install autorefs or disable the " - "plugin." - ) - - # Map glightbox plugin configuration to the extension configuration - if "glightbox" in config["plugins"]: - plugin = config["plugins"]["glightbox"]["config"] - config["markdown_extensions"].append(GlightboxExtension.name) - config["mdx_configs"][GlightboxExtension.name] = plugin - - # Map macros plugin configuration to the extension configuration - if "macros" in config["plugins"]: - plugin = config["plugins"]["macros"]["config"] - config["markdown_extensions"].append(MacrosExtension.name) - config["mdx_configs"][MacrosExtension.name] = plugin + # Map plugins configuration to Markdown extensions + _shim_mkdocstrings_autorefs(config, path) + _shim_glightbox(config) + _shim_macros(config) # List files along with their hashes, so we can rebuild when they change config["watched_files"] = sorted( @@ -656,6 +716,128 @@ def _hash(data: Any) -> int: return int(hash.hexdigest(), 16) % (2**64) +# ---------------------------------------------------------------------------- + + +def _resolve(symbol: str) -> Any: + """Resolve a symbol to its corresponding Python object.""" + module_path, func_name = symbol.rsplit(".", 1) + module = importlib.import_module(module_path) + return getattr(module, func_name) + + +def _resolve_pymdownx_emoji(config: dict[str, Any]) -> None: + # Emoji extension: resolve emoji generator and index functions + emoji = config["mdx_configs"].get("pymdownx.emoji", {}) + if isinstance(emoji.get("emoji_generator"), str): + emoji["emoji_generator"] = _resolve(emoji.get("emoji_generator")) + if isinstance(emoji.get("emoji_index"), str): + emoji["emoji_index"] = _resolve(emoji.get("emoji_index")) + + +def _resolve_pymdownx_tabbed(config: dict[str, Any]) -> None: + # Tabbed extension: resolve slugification function + tabbed = config["mdx_configs"].get("pymdownx.tabbed", {}) + if isinstance(tabbed.get("slugify"), dict): + object = tabbed["slugify"].get("object", "pymdownx.slugs.slugify") + tabbed["slugify"] = _resolve(object)( + **tabbed["slugify"].get("kwds", {}) + ) + + +def _resolve_pymdownx_blocks_tab(config: dict[str, Any]) -> None: + # Blocks-tab extension: resolve slugification function + blocks_tab = config["mdx_configs"].get("pymdownx.blocks.tab", {}) + if isinstance(blocks_tab.get("slugify"), dict): + object = blocks_tab["slugify"].get("object", "pymdownx.slugs.slugify") + blocks_tab["slugify"] = _resolve(object)( + **blocks_tab["slugify"].get("kwds", {}) + ) + + +def _resolve_pymdownx_superfences(config: dict[str, Any]) -> None: + # Superfences extension: resolve format and validator functions + superfences = config["mdx_configs"].get("pymdownx.superfences", {}) + for fence in superfences.get("custom_fences", []): + if isinstance(fence.get("format"), str): + fence["format"] = _resolve(fence.get("format")) + elif isinstance(fence.get("format"), dict): + object = fence["format"].get( + "object", "pymdownx.superfences.fence_code_format" + ) + fence["format"] = _resolve(object)( + **fence["format"].get("kwds", {}) + ) + if isinstance(fence.get("validator"), str): + fence["validator"] = _resolve(fence.get("validator")) + elif isinstance(fence.get("validator"), dict): + object = fence["validator"].get("object") + callable_object = ( + _resolve(object) if object else lambda *args, **kwargs: True + ) + fence["validator"] = callable_object( + **fence["validator"].get("kwds", {}) + ) + + +def _resolve_toc(config: dict[str, Any]) -> None: + # Table of contents extension: resolve slugification function + toc = config["mdx_configs"]["toc"] + if isinstance(toc.get("slugify"), dict): + object = toc["slugify"].get("object", "pymdownx.slugs.slugify") + toc["slugify"] = _resolve(object)(**toc["slugify"].get("kwds", {})) + + +# ---------------------------------------------------------------------------- + + +def _shim_mkdocstrings_autorefs(config: dict[str, Any], path: str) -> None: + # Set up mkdocstrings, which touches plugins and Markdown extensions + if "mkdocstrings" in config["plugins"]: + mkdocstrings_config = config["plugins"]["mkdocstrings"]["config"] + if mkdocstrings_config.get("enabled", True): + if not find_spec("mkdocstrings"): + raise ConfigurationError( + "mkdocstrings plugin is enabled, but mkdocstrings is not " + "installed. Please install mkdocstrings or disable the " + "plugin." + ) + if autorefs := get_autorefs_extension(): + config["markdown_extensions"].append(autorefs) + mkdocstrings = get_mkdocstrings_extension(config, path) + config["markdown_extensions"].append(mkdocstrings) + + # Support autorefs in standalone mode + elif "autorefs" in config["plugins"]: + if autorefs := get_autorefs_extension(): + config["markdown_extensions"].append(autorefs) + else: + raise ConfigurationError( + "autorefs plugin is enabled, but autorefs is not " + "installed. Please install autorefs or disable the " + "plugin." + ) + + +def _shim_glightbox(config: dict[str, Any]) -> None: + # Map glightbox plugin configuration to the extension configuration + if "glightbox" in config["plugins"]: + plugin = config["plugins"]["glightbox"]["config"] + config["markdown_extensions"].append(GlightboxExtension.name) + config["mdx_configs"][GlightboxExtension.name] = plugin + + +def _shim_macros(config: dict[str, Any]) -> None: + # Map macros plugin configuration to the extension configuration + if "macros" in config["plugins"]: + plugin = config["plugins"]["macros"]["config"] + config["markdown_extensions"].append(MacrosExtension.name) + config["mdx_configs"][MacrosExtension.name] = plugin + + +# ---------------------------------------------------------------------------- + + def _ignore_directory(dirpath: str) -> bool: """Determine whether to ignore a folder based on its name.""" return dirpath == "__pycache__" or dirpath.startswith(".venv") @@ -800,37 +982,14 @@ def _list_templates(config: dict) -> list[tuple[str, int]]: return sorted(files_with_mtime) -def _convert_extra(data: dict | list) -> dict | list: - """Recursively convert None values in a dictionary/list to empty strings.""" - if isinstance(data, dict): - # Process each key-value pair in the dictionary - return { - key: _convert_extra(value) - if isinstance(value, (dict, list)) - else ("" if value is None else value) - for key, value in data.items() - } - if isinstance(data, list): - # Process each item in the list - return [ - _convert_extra(item) - if isinstance(item, (dict, list)) - else ("" if item is None else item) - for item in data - ] - return data - - -def _resolve(symbol: str) -> Any: - """Resolve a symbol to its corresponding Python object.""" - module_path, func_name = symbol.rsplit(".", 1) - module = importlib.import_module(module_path) - return getattr(module, func_name) - - # ----------------------------------------------------------------------------- +def _is_index(path: str) -> bool: + """Returns, whether the given path points to a section index.""" + return os.path.basename(path) in ("index.md", "README.md") + + def _convert_nav(nav: list) -> list: """Convert MkDocs navigation.""" return [_convert_nav_item(entry) for entry in nav] @@ -884,12 +1043,25 @@ def _convert_nav_item(item: str | dict | list) -> dict | list: raise TypeError(f"Unknown nav item type: {type(item)}") -def _is_index(path: str) -> bool: - """Returns, whether the given path points to a section index.""" - return os.path.basename(path) in ("index.md", "README.md") - - -# ----------------------------------------------------------------------------- +def _convert_extra(data: dict | list) -> dict | list: + """Recursively convert None values in a dictionary/list to empty strings.""" + if isinstance(data, dict): + # Process each key-value pair in the dictionary + return { + key: _convert_extra(value) + if isinstance(value, (dict, list)) + else ("" if value is None else value) + for key, value in data.items() + } + if isinstance(data, list): + # Process each item in the list + return [ + _convert_extra(item) + if isinstance(item, (dict, list)) + else ("" if item is None else item) + for item in data + ] + return data def _convert_extra_javascript(value: list) -> list: @@ -914,9 +1086,6 @@ def _convert_extra_javascript(value: list) -> list: return value -# ----------------------------------------------------------------------------- - - def _convert_markdown_extensions(value: Any) -> tuple[list[str], dict]: """Convert Markdown extensions to what Python Markdown expects.""" markdown_extensions = ["toc", "tables"] @@ -967,9 +1136,6 @@ def _convert_markdown_extensions(value: Any) -> tuple[list[str], dict]: return list(set(markdown_extensions)), mdx_configs -# ---------------------------------------------------------------------------- - - def _convert_plugins(value: Any, config: dict) -> dict: """Convert plugins configuration to something we can work with.""" plugins = {} @@ -1048,138 +1214,3 @@ def _convert_plugins(value: Any, config: dict) -> dict: # Return plugins return plugins - - -# ---------------------------------------------------------------------------- - - -def _yaml_load(source: IO) -> dict[str, Any]: - """Load configuration file, resolve environment variables and parent files. - - Note that INHERIT is only a bandaid that was introduced to allow for some - degree of modularity, but with serious shortcomings. Zensical will use a - different approach in the future, which will allow for composable and - environment-specific configuration. - """ - Loader.add_constructor("!ENV", _construct_env_tag) - try: - config = yaml.load( - # Compatibility shim: we remap Material's extension namespace to - # Zensical's, and the now deprecated materialx namespace as well - source.read() - .replace("material.extensions", "zensical.extensions") - .replace("materialx", "zensical.extensions"), - Loader=Loader, # noqa: S506 - ) - except YAMLError as e: - raise ConfigurationError( - f"Encountered an error parsing the configuration file: {e}" - ) from e - if config is None: - return {} - - # Try to resolve inherited configuration file - if "INHERIT" in config and not isinstance(source, str): - relpath = config.pop("INHERIT") - abspath = os.path.normpath( - os.path.join(os.path.dirname(source.name), relpath) - ) - if not os.path.exists(abspath): - raise ConfigurationError( - f"Inherited config file '{relpath}' " - f"doesn't exist at '{abspath}'." - ) - with open(abspath, encoding="utf-8") as fd: - parent = _yaml_load(fd) - config = always_merger.merge(parent, config) - - # Return resulting configuration - return config - - -def _yaml_load_theme_config(source: IO) -> dict[str, Any]: - Loader.add_constructor("!ENV", _construct_env_tag) - try: - config = yaml.load(source, Loader=Loader) # noqa: S506 - except YAMLError as e: - raise ConfigurationError( - f"Encountered an error parsing the theme configuration file: {e}" - ) from e - if config is None: - return {} - return config - - -def _load_theme_config(theme_dir: str) -> tuple[dict[str, Any], list[str]]: - # Keep track of the theme directories, - # so that we can pass them up to Minijinja. - theme_dirs = [theme_dir] - - if (theme_config_file := Path(theme_dir, "mkdocs_theme.yml")).is_file(): - # We found a theme configuration file (mkdocs_theme.yml). - with theme_config_file.open(encoding="utf-8") as file: - theme_config = _yaml_load_theme_config(file) - if extends := theme_config.pop("extends", None): - # This theme extends another theme, - # so we load this parent theme's configuration (recursively) - # and merge the current theme's configuration into it. - parent_theme_dir = get_theme_dir(extends) - parent_config, parent_dirs = _load_theme_config( - parent_theme_dir - ) - parent_config.update(theme_config) - theme_dirs.extend(parent_dirs) - return parent_config, theme_dirs - # Otherwise we just return the current theme configuration. - return theme_config, theme_dirs - - # No theme configuration. - return {}, theme_dirs - - -def _construct_env_tag( - loader: yaml.Loader, - node: yaml.ScalarNode | yaml.SequenceNode | yaml.MappingNode, -) -> Any: - """Assign value of ENV variable referenced at node. - - MkDocs supports the use of !ENV to reference environment variables in YAML - configuration files. We won't likely support this in Zensical, but for now - we need it to build MkDocs projects. Zensical will use a different approach - to create environment-specific configuration in the future. - - Licensed under MIT - Copyright (c) 2020 Waylan Limberg - Taken and adapted from - https://github.com/waylan/pyyaml-env-tag/blob/master/yaml_env_tag.py - """ - default = None - - # Handle !ENV - if isinstance(node, yaml.nodes.ScalarNode): - vars = [loader.construct_scalar(node)] - - # Handle !ENV [, ] - elif isinstance(node, yaml.nodes.SequenceNode): - child_nodes = node.value - if len(child_nodes) > 1: - default = loader.construct_object(child_nodes[-1]) - child_nodes = child_nodes[:-1] - # Env Vars are resolved as string values, ignoring (implicit) types. - vars = [loader.construct_scalar(child) for child in child_nodes] - else: - raise ConstructorError( - context=f"expected a scalar or sequence node, but found {node.id}", - context_mark=node.start_mark, - ) - - # Resolve environment variable - for var in vars: - if var in os.environ: - value = os.environ[var] - # Resolve value to Python type using YAML's implicit resolvers - tag = loader.resolve(yaml.nodes.ScalarNode, value, (True, False)) - return loader.construct_object(yaml.nodes.ScalarNode(tag, value)) - - # Otherwise return default - return default