From 4f96dd5b56817275fa7d5597b4bbac394d07b20e Mon Sep 17 00:00:00 2001 From: Laurent Franceschetti Date: Sat, 27 Apr 2024 20:52:17 +0200 Subject: [PATCH] Generate a Warning in case of variable/macro error (#226) - Generated a warning so as to make the build fail in strict mode (`mkdocs build --strict`) - Updated documentation of Advanced usage and Troubleshooting to clarify this. - Bumped revision number. --- plugin.py | 809 +++++++++++++++++++++++++++++++++++++ setup.py | 2 +- test/simple/docs/index.md | 3 + test/simple/docs/second.md | 8 + test/simple/mkdocs.yml | 1 + util.py | 245 +++++++++++ 6 files changed, 1067 insertions(+), 1 deletion(-) create mode 100644 plugin.py create mode 100644 test/simple/docs/second.md create mode 100644 util.py diff --git a/plugin.py b/plugin.py new file mode 100644 index 0000000..00aa362 --- /dev/null +++ b/plugin.py @@ -0,0 +1,809 @@ +# -------------------------------------------- +# Main part of the plugin +# Defines the VariablesPlugin class +# +# Laurent Franceschetti (c) 2018 +# MIT License +# -------------------------------------------- + +import importlib +import os +from copy import copy + +import yaml +from jinja2 import ( + Environment, FileSystemLoader, Undefined, DebugUndefined, StrictUndefined, +) +import pathspec + +from mkdocs.config import config_options +from mkdocs.config.config_options import Type as PluginType +from mkdocs.plugins import BasePlugin +from mkdocs.structure.pages import Page + +from mkdocs_macros.errors import format_error +from mkdocs_macros.context import define_env +from mkdocs_macros.util import ( + install_package, parse_package, trace, debug, + update, SuperDict, import_local_module, format_chatter, LOG, +) + +# ------------------------------------------ +# Initialization +# ------------------------------------------ + +# The subsets of the YAML file that will be used for the variables: +YAML_VARIABLES = 'extra' + +# The default name of the Python module: +DEFAULT_MODULE_NAME = 'main' # main.py + +# Possible behavior in case of ignored variables or macros (first is default) + + +class LaxUndefined(Undefined): + "Pass anything wrong as blank" + + def _fail_with_undefined_error(self, *args, **kwargs): + return '' + + +UNDEFINED_BEHAVIOR = {'keep': DebugUndefined, + 'silent': Undefined, + 'strict': StrictUndefined, + # lax will even pass unknown objects: + 'lax': LaxUndefined} + +# By default undefined jinja2 variables AND macros will be left as-is +# see https://stackoverflow.com/a/53134416 +DEFAULT_UNDEFINED_BEHAVIOR = 'keep' + +# Return codes in case of error +ERROR_MACRO = 100 + + +# ------------------------------------------ +# Plugin +# ------------------------------------------ + + +class MacrosPlugin(BasePlugin): + """ + Inject config 'extra' variables into the markdown + plus macros / variables defined in external module. + + The python code is located in 'main.py' or in a 'main' package + in the root directory of the website + (unless you want to redefine that name in the 'python_module' value + in the mkdocs.yml file) + """ + + # what is under the 'macros' namespace (will go into the config property): + J2_STRING = PluginType(str, default='') + config_scheme = ( + # main python module: + ('module_name', PluginType(str, + default=DEFAULT_MODULE_NAME)), + ('modules', PluginType(list, + default=[])), + # How to render pages by default: yes (opt-out), no (opt-in) + ('render_by_default', PluginType(bool, default=True)), + # Force the rendering of those directories and files + # Use Pathspec syntax (similar to gitignore) + # see: https://python-path-specification.readthedocs.io/en/stable/readme.html#tutorial + # this is relative to doc_dir + ('force_render_paths', J2_STRING), + # Include directory for external files + # also works for {% include ....%}) and {% import ....%}): + ('include_dir', J2_STRING), + # list of additional yaml files: + ('include_yaml', PluginType(list, default=[])), + # for altering the j2 markers, in case of need: + # https://jinja.palletsprojects.com/en/latest/api/ + ('j2_block_start_string', J2_STRING), + ('j2_block_end_string', J2_STRING), + ('j2_variable_start_string', J2_STRING), + ('j2_variable_end_string', J2_STRING), + ('j2_comment_start_string', J2_STRING), + ('j2_comment_end_string', J2_STRING), + # for behavior of unknown macro (e.g. other plugin): + ('on_undefined', PluginType(str, default=DEFAULT_UNDEFINED_BEHAVIOR)), + # for CD/CI set that parameter to true + ('on_error_fail', PluginType(bool, default=False)), + ('verbose', PluginType(bool, default=False)) + ) + + def start_chatting(self, prefix: str, color: str = 'yellow'): + "Generate a chatter function (trace for macros)" + def chatter(*args): + """ + Defines a tracer for the Verbose mode, to be used in macros. + If `verbose: true` in the YAML config file (under macros plugin), + it will start "chattering" + (talking a lot and in a friendly way, + about mostly unimportant things). + Otherwise, it will remain silent. + + If you change the `verbose` while the local server is activated, + (`mkdocs server`) this should be instantly reflected. + + Usage: + ----- + chatter = env.make_chatter('MY_MODULE_NAME') + chatter("This is a dull debug message.") + + Will result in: + + INFO - [macros - Simple module] - This is a dull info message. + """ + if self.config['verbose']: + LOG.info(format_chatter(*args, prefix=prefix, color=color)) + + return chatter + + # ------------------------------------------------ + # These properties are available in the env object + # ------------------------------------------------ + + @property + def conf(self): + """ + Dictionary containing of the whole config file (by default: mkdocs.yml) + + This property may be useful if the code in the module needs to access + general configuration information. + + NOTE: this property is called 'conf', because there is already + a 'config' property in a BasePlugin object, + which is the data connected to the macros plugin + (in the yaml file) + """ + try: + return self._conf + except AttributeError: + raise AttributeError("Conf property of macros plugin " + "was called before it was initialized!") + + @property + def variables(self): + "The cumulative list of variables, initialized by on_config()" + try: + return self._variables + except AttributeError: + raise AttributeError("Property called before on_config()") + + @property + def macros(self): + "The cumulative list of macros, initialized by on_config()" + try: + return self._macros + except AttributeError: + raise AttributeError("Property called before on_config()") + + @property + def filters(self): + "The list of filters defined in the module, initialized by on_config()" + try: + return self._filters + except AttributeError: + self._filters = {} + return self._filters + + @property + def project_dir(self) -> str: + "The directory of project" + # we calculate it from the configuration file + CONFIG_FILE = self.conf['config_file_path'] + return os.path.dirname(os.path.abspath(CONFIG_FILE)) + + def macro(self, v, name=''): + """ + Registers a variable as a macro in the template, + i.e. in the variables dictionary: + + env.macro(myfunc) + + Optionally, you can assign a different name: + + env.macro(myfunc, 'funcname') + + + You can also use it as a decorator: + + @env.macro + def foo(a): + return a ** 2 + + More info: + https://stackoverflow.com/questions/6036082/call-a-python-function-from-jinja2 + """ + + name = name or v.__name__ + self.macros[name] = v + return v + + def filter(self, v, name=''): + """ + Register a filter in the template, + i.e. in the filters dictionary: + + env.filter(myfunc) + + Optionally, you can assign a different name: + + env.filter(myfunc, 'filtername') + + + You can also use it as a decorator: + + @env.filter + def reverse(x): + "Reverse a string (and uppercase)" + return x.upper().[::-1] + + See: https://jinja.palletsprojects.com/en/2.10.x/api/#custom-filters + """ + + name = name or v.__name__ + self.filters[name] = v + return v + + @property + def page(self) -> Page: + """ + The current page's information + """ + try: + return self._page + except AttributeError: + raise AttributeError("Too early: page information is not available" + "at this stage!") + + @property + def markdown(self) -> str: + """ + The markdown of the current page, after interpretation + """ + try: + return self._markdown + except AttributeError: + raise AttributeError("Too early: raw markdown is not available" + "at this stage!") + + @markdown.setter + def markdown(self, value): + """ + Used to set the raw markdown of the current page + """ + if not isinstance(value, str): + raise ValueError("Value provided to attribute markdown " + "should be a string") + # check whether attribute is accessible: + self.markdown + self._markdown = value + + + @property + def raw_markdown(self) -> str: + """ + Cancelled attribute + """ + trace("Property env.raw_markdown is removed " + "as of 1.1.0; use env.markdown instead!") + return self.markdown(self) + + @markdown.setter + def raw_markdown(self, value): + """ + Used to set the raw markdown + """ + trace("Property env.raw_markdown is removed " + "as of 1.1.0; use env.markdown instead!") + self.markdown = value + + # ---------------------------------- + # Function lists, for later events + # ---------------------------------- + + @property + def pre_macro_functions(self): + """ + List of pre-macro functions contained in modules. + These are deferred to the on_page_markdown() event. + """ + try: + return self._pre_macro_functions + except AttributeError: + raise AttributeError("You called the pre_macro_functions property " + "too early. Does not exist yet !") + + @property + def post_macro_functions(self): + """ + List of post-macro functions contained in modules. + These are deferred to the on_page_markdown() event. + """ + try: + return self._post_macro_functions + except AttributeError: + raise AttributeError("You called the post_macro_functions property " + "too early. Does not exist yet !") + + @property + def post_build_functions(self): + """ + List of post build functions contained in modules. + These are deferred to the on_post_build() event. + """ + try: + return self._post_build_functions + except AttributeError: + raise AttributeError("You called post_build_functions property " + "too early. Does not exist yet !") + + def force_page_rendering(self, filename:str)->bool: + """ + Predicate: it defines whether the rendering of this page + filename must be forced + (because it is in the `force_render_paths` parameters). + + That parameterer is parsed in on_config() and used to define + `render_paths_spec`. + + """ + try: + return self._render_paths_spec.match_file(filename) + except AttributeError: + raise AttributeError("You called the force_render() method " + "too early. Not initialized yet !") + + # -----------------------s----------- + # load elements + # ---------------------------------- + + def _load_yaml(self): + "Load the the external yaml files" + for el in self.config['include_yaml']: + # el is either a filename or {key: filename} single-entry dict + try: + [[key, filename]] = el.items() + except AttributeError: + key = None + filename = el + # Paths are be relative to the project root. + filename = os.path.join(self.project_dir, filename) + if os.path.isfile(filename): + with open(filename) as f: + # load the yaml file + # NOTE: for the SafeLoader argument, see: https://github.com/yaml/pyyaml/wiki/PyYAML-yaml.load(input)-Deprecation + content = yaml.load(f, Loader=yaml.SafeLoader) + trace("Loading yaml file:", filename) + if key is not None: + content = {key: content} + update(self.variables, content) + else: + trace("WARNING: YAML configuration file was not found!", + filename) + + def _load_module(self, module, module_name): + """ + Load a single module + + Add variables and functions to the config dictionary, + via the python module + (located in the same directory as the Yaml config file). + + This function enriches the variables dictionary + + The python module must contain the following hook: + + define_env(env): + "Declare environment for jinja2 templates for markdown" + + env.variables['a'] = 5 + + @env.macro + def bar(x): + ... + + @env.macro + def baz(x): + ... + + @env.filter + def foobar(x): + ... + + """ + if not module: + return + trace("Found external Python module '%s' in:" % module_name, + self.project_dir) + # execute the hook for the macros + function_found = False + if hasattr(module, 'define_env'): + module.define_env(self) + function_found = True + + # DECLARE additional event functions + # NOTE: each of these functions requires self (the environment). + STANDARD_FUNCTIONS = ['define_env'] + def add_function(funcname: str, funclist: list): + "Add another standard function to the module" + STANDARD_FUNCTIONS.append(funcname) + if hasattr(module, funcname): + nonlocal function_found + func = getattr(module, funcname) + funclist.append(func) + function_found = True + add_function('on_pre_page_macros', self.pre_macro_functions) + add_function('on_post_page_macros', self.post_macro_functions) + add_function('on_post_build', self.post_build_functions) + print(STANDARD_FUNCTIONS) + if not function_found: + raise NameError("None of the standard functions was found " + "in module '%s':\n%s" % + (module_name, STANDARD_FUNCTIONS)) + + def _load_modules(self): + "Load all modules" + self._pre_macro_functions = [] + self._post_macro_functions = [] + self._post_build_functions = [] + + # pluglets installed modules (as in pip list) + modules = self.config['modules'] + if modules: + trace("Preinstalled modules: ", ','.join(modules)) + for m in modules: + # split the name of package in source (pypi) and module name + source_name, module_name = parse_package(m) + try: + module = importlib.import_module(module_name) + except ModuleNotFoundError: + try: + # if absent, install (from pypi) + trace("Module '%s' not found, installing (source: '%s')" % + (module_name, source_name)) + install_package(source_name) + # install package raises NameError + module = importlib.import_module(module_name) + except (NameError, ModuleNotFoundError): + raise ModuleNotFoundError("Could not import installed " + "module '%s' (missing?)" % + module_name, + name=module_name) + self._load_module(module, module_name) + # local module (file or dir) + local_module_name = self.config['module_name'] + debug("Project dir '%s'" % self.project_dir) + module = import_local_module(self.project_dir, local_module_name) + if module: + trace("Found local Python module '%s' in:" % local_module_name, + self.project_dir) + self._load_module(module, local_module_name) + + else: + if local_module_name == DEFAULT_MODULE_NAME: + # do not do anything if there is no main module + trace("No default module `%s` found" % DEFAULT_MODULE_NAME) + else: + raise ImportError("Macro plugin could not find custom '%s' " + "module in '%s'." % + (local_module_name, self.project_dir)) + + def render(self, markdown: str, force_rendering:bool=False): + """ + Render a page through jinja2: it reads the code and + executes the macros. + + It tests the `render_macros` metavariable + in the page's header to decide whether to actually + render or not (but you can force it). + + PRINCIPLE OF PRECAUTION: + If the YAML header of the page contains `render_macros: false`: + that takes priority: + NO rendering will be done, and the markdown will be returned + as is (even if `force_rendering` is set to true). + + + Arguments + --------- + - markdown: the markdown/HTML page (with the jinja2 macros) + - force_rendering: if True, it forces the rendering, + even if the page header doesn't say so + (used in the case when `render_by_default` is set to false + in the config file) + + Returns + ------- + A pure markdown/HTML page. + + Notes + ----- + - Must called by _on_page_markdown() + """ + + # Process meta_variables + # ---------------------- + # copy the page variables and update with the meta data + # in the YAML header: + page_variables = copy(self.variables) + try: + meta_variables = self.variables['page'].meta + except KeyError as e: + # this is a premature rendering, no meta variables in the page + meta_variables = {} + + + # Warning this is ternary logic(True, False, None: nothing said) + render_macros = None + + if meta_variables: + # determine whether the page will be rendered or not + # the two formulations are accepted + render_macros = meta_variables.get('render_macros') + # ignore_macros should be phased out + if meta_variables.get('ignore_macros'): + raise ValueError("The metavariable `ignore_macros` " + "is now FORBIDDEN " + "in the header of markdown pages, " + "use `render_macros` instead.") + + # this takes precedence over any other consideration: + if render_macros == False: + return markdown + + if self.config['render_by_default'] == False: + # opt-in + if force_rendering or render_macros == True: + pass # opt-in + else: + return markdown + + # Update the page with meta variables + # i.e. what's in the yaml header of the page + page_variables.update(meta_variables) + + # Rendering + # ---------------------- + # expand the template + on_error_fail = self.config['on_error_fail'] + try: + md_template = self.env.from_string(markdown) + # Execute the jinja2 template and return + return md_template.render(**page_variables) + + except Exception as error: + error_message = format_error( + error, + markdown=markdown, + page=self.page, + ) + + trace('ERROR', error_message, level='warning') + if on_error_fail: + exit(ERROR_MACRO) + + else: + return error_message + + # ---------------------------------- + # Standard Hooks for a mkdocs plugin + # ---------------------------------- + + def on_config(self, config): + """ + Called once (initialization) + From the configuration file, builds a Jinj2 environment + with variables, functions and filters. + """ + # WARNING: this is not the config argument: + trace("Macros arguments:", self.config) + # define the variables and macros as dictionaries + # (for update function to work): + self._variables = SuperDict() + self._macros = SuperDict() + + # load the extra variables + extra = dict(config.get(YAML_VARIABLES)) + # make a copy for documentation: + self.variables['extra'] = extra + # actual variables (top level will be loaded later) + + # export the whole data passed as argument, in case of need: + self._conf = config + # add a copy to the template variables + # that copy may be manipulated + self.variables['config'] = copy(config) + assert self.variables['config'] is not config + + # load other yaml files + self._load_yaml() + + # load the standard plugin context + define_env(self) + + # at this point load the actual variables from extra (YAML file) + self.variables.update(extra) + + # add variables, functions and filters from the Python module: + # by design, this MUST be the last step, so that programmers have + # full control on what happened in the configuration files + self._load_modules() + # Provide information: + debug("Variables:", list(self.variables.keys())) + if len(extra): + trace("Extra variables (config file):", list(extra.keys())) + debug("Content of extra variables (config file):", extra) + if self.filters: + trace("Extra filters (module):", list(self.filters.keys())) + + + # Define the spec for the file paths whose rendering must be forced. + # It will be used by the force_page_rendering() predicate: + force_render_paths = self.config['force_render_paths'] + self._render_paths_spec = pathspec.PathSpec.from_lines( + 'gitwildmatch', + force_render_paths.splitlines()) + + # ------------------- + # Create the jinja2 environment: + # ------------------- + DOCS_DIR = config.get('docs_dir') + debug("Docs directory:", DOCS_DIR) + # define the include directory: + # NOTE: using DOCS_DIR as default is not ideal, + # because those files get rendered as well, which is incorrect + # since they are partials; but we do not want to break existing installs + include_dir = self.config['include_dir'] or DOCS_DIR + if not os.path.isdir(include_dir): + raise FileNotFoundError("MACROS ERROR: Include directory '%s' " + "does not exist!" % + include_dir) + if self.config['include_dir']: + trace("Includes directory:", include_dir) + else: + debug("Includes directory:", include_dir) + # get the behavior in case of unknown variable (default: keep) + on_undefined = self.config['on_undefined'] + if on_undefined not in UNDEFINED_BEHAVIOR: + raise ValueError("Illegal value for undefined macro parameter '%s'" % on_undefined) + undefined = UNDEFINED_BEHAVIOR[on_undefined] + debug("Undefined behavior:", undefined) + env_config = { + 'loader': FileSystemLoader(include_dir), + 'undefined': undefined + } + # read the config variables for jinja2: + for key, value in self.config.items(): + # take definitions in config_scheme where key starts with 'j2_' + # (if value is not empty) + # and forward them to jinja2 + # this is used for the markers + if key.startswith('j2_') and value: + variable_name = key.split('_', 1)[1] # remove prefix + trace("Found j2 variable '%s': '%s'" % + (variable_name, value)) + env_config[variable_name] = value + + # finally build the environment: + self.env = Environment(**env_config) + + # ------------------- + # Process macros + # ------------------- + # reference all macros + self.variables['macros'] = copy(self.macros) + # add the macros to the environment's global (not to the template!) + self.env.globals.update(self.macros) + + # ------------------- + # Process filters + # ------------------- + # reference all filters, for doc [these are copies, so no black magic] + # NOTE: self.variables is reflected in the list of variables + # in the jinja2 environment (same object) + self.variables['filters'] = copy(self.filters) + self.variables['filters_builtin'] = copy(self.env.filters) + # update environment with the custom filters: + self.env.filters.update(self.filters) + + def on_nav(self, nav, config, files): + """ + Called after the site navigation is created. + Capture the nav and files objects so they can be used by + templates. + """ + # nav has useful properties like 'pages' and 'items' + # see: https://github.com/mkdocs/mkdocs/blob/master/mkdocs/structure/nav.py + self.variables['navigation'] = nav + # files has collection of files discovered in docs_dir + # see: https://github.com/mkdocs/mkdocs/blob/master/mkdocs/structure/files.py + # NOTE: useful for writing macros that check for the existence of files; e.g., a macro to mark a link as disabled, if its target doesn't exist + self.variables['files'] = files + + def on_serve(self, server, config, **kwargs): + """ + Called when the serve command is used during development. + This is to add files or directories to the list of "watched" + files for auto-reloading. + """ + # define directories to add, keep non nulls + additional = [self.config['include_dir'] # markdown includes + ] + additional = [el for el in additional if el] + if additional: + trace("We will also watch:", additional) + # necessary because of a bug in mkdocs: + # more information in: + # https://github.com/mkdocs/mkdocs/issues/1952)) + try: + builder = list(server.watcher._tasks.values())[0]["func"] + except AttributeError: + # change in mkdocs 1.2, see: https://www.mkdocs.org/about/release-notes/#backward-incompatible-changes-in-12 + # this parameter is now optional + builder = None + # go ahead and watch + for el in additional: + if el: + server.watch(el, builder) + + def on_page_markdown(self, markdown, page:Page, + config, **kwargs): + """ + Pre-rendering for each page of the website. + It uses the jinja2 directives, together with + variables, macros and filters, to create pure markdown code. + """ + # the site_navigation argument has been made optional + # (deleted in post-1.0 mkdocs, but maintained here + # for backward compatibility) + # We REALLY want the same object + self._page = page + if not self.variables: + return markdown + else: + # Update the page info in the document + # page is an object with a number of properties (title, url, ...) + # see: https://github.com/mkdocs/mkdocs/blob/master/mkdocs/structure/pages.py + self.variables["page"] = copy(page) + # Define whether we must force the rendering of this page, + # based on filename (relative to docs_dir directory) + filename = page.file.src_path + force_rendering = self.force_page_rendering(filename) + + # set the markdown (for the first time) + self._markdown = markdown + # execute the pre-macro functions in the various modules + for func in self.pre_macro_functions: + func(self) + # render the macros + self.markdown = self.render( + markdown=self.markdown, + force_rendering=force_rendering + ) + # Convert macros in the title from render (if exists) + # to answer 144 + # There is a bizarre issue #215 where setting the title + # prevents interpretation of icons with pymdownx.emoji + debug("Page title:",page.title) + if "{" in page.title: + page.title = self.render(markdown=page.title, + force_rendering=force_rendering) + debug("Page title after macro rendering:",page.title) + + # execute the post-macro functions in the various modules + for func in self.post_macro_functions: + func(self) + return self.markdown + + def on_post_build(self, config: config_options.Config): + """ + Hook for post build actions, typically adding + raw files to the setup. + """ + # execute the functions in the various modules + for func in self.post_build_functions: + func(self) diff --git a/setup.py b/setup.py index 0ceb7b5..e17626f 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ # Initialization # -------------------- -VERSION_NUMBER = '1.1.1' +VERSION_NUMBER = '1.1.2' # required if you want to run document/test # pip install 'mkdocs-macros-plugin[test]' diff --git a/test/simple/docs/index.md b/test/simple/docs/index.md index 91599ff..aadfccd 100644 --- a/test/simple/docs/index.md +++ b/test/simple/docs/index.md @@ -1,2 +1,5 @@ +# Main Page + {{ macros_info() }} + diff --git a/test/simple/docs/second.md b/test/simple/docs/second.md new file mode 100644 index 0000000..00b7fd9 --- /dev/null +++ b/test/simple/docs/second.md @@ -0,0 +1,8 @@ +# Cause a warning + +This variable {{ foo() }} does not exist. + +In the default config (`on_undefined: keep`) it won't make the build fail. +But it will generate a warning, so this page is NOT displayed. + +This error is INTENTIONAL. \ No newline at end of file diff --git a/test/simple/mkdocs.yml b/test/simple/mkdocs.yml index faa13fd..5358511 100644 --- a/test/simple/mkdocs.yml +++ b/test/simple/mkdocs.yml @@ -3,6 +3,7 @@ theme: mkdocs nav: - Home: index.md + - Error Page (intentional): second.md plugins: - search diff --git a/util.py b/util.py new file mode 100644 index 0000000..c5ec4c4 --- /dev/null +++ b/util.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 + +""" +Utilities for mkdocs-macros +""" + +import subprocess +from copy import deepcopy +import os, sys, importlib.util +from typing import Literal +from packaging.version import Version + +from termcolor import colored +import mkdocs + +# ------------------------------------------ +# Trace and debug +# ------------------------------------------ +TRACE_COLOR = 'green' +TRACE_PREFIX = 'macros' + +import logging +LOG = logging.getLogger("mkdocs.plugins." + __name__) + +MKDOCS_LOG_VERSION = '1.2' +if Version(mkdocs.__version__) < Version(MKDOCS_LOG_VERSION): + # filter doesn't do anything since that version + from mkdocs.utils import warning_filter + LOG.addFilter(warning_filter) + + +def format_trace(*args): + """ + General purpose print function, as trace, + for the mkdocs-macros framework; + it will appear if --verbose option is activated + """ + first = args[0] + rest = [str(el) for el in args[1:]] + text = "[%s] - %s" % (TRACE_PREFIX, first) + emphasized = colored(text, TRACE_COLOR) + return ' '.join([emphasized] + rest) + + +TRACE_LEVELS = { + 'debug' : logging.DEBUG, + 'info' : logging.INFO, + 'warning' : logging.WARNING, + 'error' : logging.ERROR, + 'critical': logging.CRITICAL +} + +def trace(*args, level:str='info'): + """ + General purpose print function, as trace, + for the mkdocs-macros framework; + it will appear unless --quiet option is activated. + + The level is 'debug', 'info', 'warning', 'error' or 'critical'. + """ + msg = format_trace(*args) + try: + LOG.log(TRACE_LEVELS[level], msg) + except KeyError: + raise ValueError("Unknown level '%s' %s" % (level, + tuple(TRACE_LEVELS.keys()) + ) + ) + return msg + # LOG.info(msg) + + + +def debug(*args): + """ + General purpose print function, as trace, + for the mkdocs-macros framework; + it will appear if --verbose option is activated + """ + msg = format_trace(*args) + LOG.debug(msg) + + +def format_chatter(*args, prefix:str, color:str=TRACE_COLOR): + """ + Format information for env.chatter() in macros. + (This is specific for macros) + """ + full_prefix = colored('[%s - %s] -' % (TRACE_PREFIX, prefix), + color) + args = [full_prefix] + [str(arg) for arg in args] + msg = ' '.join(args) + return msg + + + + + +# ------------------------------------------ +# Packages and modules +# ------------------------------------------ + +def parse_package(package:str): + """ + Parse a package name + + if it is in the forme 'foo:bar' then 'foo' is the source, + and 'bar' is the (import) package name + + Returns the source name (for pip install) and the package name (for import) + """ + l = package.split(':') + if len(l) == 1: + source_name = package_name = l[0] + else: + source_name, package_name = l[:2] + return source_name, package_name + +def install_package(package:str): + """ + Install a package from pip + """ + try: + subprocess.check_call(["pip3", "install", package]) + except subprocess.CalledProcessError: + raise NameError("Could not install package '%s'" % package) + + +def import_local_module(project_dir, module_name): + """ + Import a module from a pathname. + """ + # get the full path + if not os.path.isdir(project_dir): + raise FileNotFoundError("Project dir does not exist: %s" % project_dir) + # there are 2 possibilities: dir or file + pathname_dir = os.path.join(project_dir, module_name) + pathname_file = pathname_dir + '.py' + if os.path.isfile(pathname_file): + spec = importlib.util.spec_from_file_location(module_name, + pathname_file) + module = importlib.util.module_from_spec(spec) + # execute the module + spec.loader.exec_module(module) + return module + elif os.path.isdir(pathname_dir): + # directory + sys.path.insert(0, project_dir) + # If the import is relative, then the package name must be given, + # so that Python always knows how to call it. + try: + return importlib.import_module(module_name, package='main') + except ImportError as e: + # BUT Python will NOT allow an import past the root of the project; + # this will fail when the module will actually be loaded. + # the only way, is to insert the directory into the path + sys.path.insert(0, module_name) + module_name = os.path.basename(module_name) + return importlib.import_module(module_name, package='main') + else: + return None + + +# ------------------------------------------ +# Utilities +# ------------------------------------------ +def update(d1, d2): + """ + Update object d1, with object d2, recursively + It has a simple behaviour: + - if these are dictionaries, attempt to merge keys + (recursively). + - otherwise simply makes a deep copy. + """ + BASIC_TYPES = (int, float, str, bool, complex) + if isinstance(d1, dict) and isinstance(d2, dict): + for key, value in d2.items(): + # print(key, value) + if key in d1: + # key exists + if isinstance(d1[key], BASIC_TYPES): + d1[key] = value + else: + update(d1[key], value) + + else: + d1[key] = deepcopy(value) + else: + # if it is any kind of object + d1 = deepcopy(d2) + +class SuperDict(dict): + """ + A dictionary accessible with the dot notation + + a['foo'] <=> a.foo + + except for standard methods + """ + + def __getattr__(self, name): + "Allow dot notation on reading" + try: + return self[name] + except KeyError: + raise AttributeError("Cannot find attribute '%s" % name) + + def __setattr__(self, name, value): + "Allow dot notation on writing" + self[name] = value + +if __name__ == '__main__': + # test merging of dictionaries + a = {'foo': 4, 'bar': 5} + b = {'foo': 5, 'baz': 6} + update(a, b) + print(a) + assert a['foo'] == 5 + assert a['baz'] == 6 + + a = {'foo': 4, 'bar': 5} + b = {'foo': 5, 'baz': ['hello', 'world']} + update(a, b) + print(a) + assert a['baz'] == ['hello', 'world'] + + + a = {'foo': 4, 'bar': {'first': 1, 'second': 2}} + b = {'foo': 5, 'bar': {'first': 2, 'third': 3}} + update(a, b) + print(a) + assert a['bar'] == {'first': 2, 'second': 2, 'third': 3} + NEW = {'hello': 5} + c = {'bar': {'third': NEW}} + update(a, c) + print(a) + assert a['bar']['third'] == NEW + + + NEW = {'first': 2, 'third': 3} + a = {'foo': 4} + b = {'bar': NEW} + update(a, b) + print(a) + assert a['bar'] == NEW