From ec9bbb987856a83fb766caf06233e7218c5d905c Mon Sep 17 00:00:00 2001 From: Laurent Franceschetti Date: Sun, 28 Apr 2024 16:36:19 +0200 Subject: [PATCH] Add missed modifications for #226 --- mkdocs_macros/plugin.py | 2 +- mkdocs_macros/util.py | 39 +- plugin.py | 809 ---------------------------------------- setup.py | 2 +- util.py | 245 ------------ 5 files changed, 25 insertions(+), 1072 deletions(-) mode change 100755 => 100644 mkdocs_macros/util.py delete mode 100644 plugin.py delete mode 100644 util.py diff --git a/mkdocs_macros/plugin.py b/mkdocs_macros/plugin.py index 4f7f35c..00aa362 100644 --- a/mkdocs_macros/plugin.py +++ b/mkdocs_macros/plugin.py @@ -582,7 +582,7 @@ def render(self, markdown: str, force_rendering:bool=False): page=self.page, ) - trace('ERROR', error_message) + trace('ERROR', error_message, level='warning') if on_error_fail: exit(ERROR_MACRO) diff --git a/mkdocs_macros/util.py b/mkdocs_macros/util.py old mode 100755 new mode 100644 index ce4837f..c5ec4c4 --- a/mkdocs_macros/util.py +++ b/mkdocs_macros/util.py @@ -7,6 +7,7 @@ import subprocess from copy import deepcopy import os, sys, importlib.util +from typing import Literal from packaging.version import Version from termcolor import colored @@ -34,33 +35,39 @@ def format_trace(*args): for the mkdocs-macros framework; it will appear if --verbose option is activated """ - # full_prefix = colored(TRACE_PREFIX, TRACE_COLOR) - # args = [full_prefix] + [str(arg) for arg in args] - # msg = ' '.join(args) 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) -# def trace(*args, prefix=TRACE_PREFIX, **kwargs): -# """ -# General purpose print function, with first item emphasized (color) -# This is NOT debug: it will always be printed -# """ -# first = args[0] -# rest = args[1:] -# text = "[%s] %s" % (prefix, first) -# emphasized = colored(text, TRACE_COLOR) -# print(emphasized, *rest, **kwargs) -def trace(*args): + +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 + it will appear unless --quiet option is activated. + + The level is 'debug', 'info', 'warning', 'error' or 'critical'. """ msg = format_trace(*args) - LOG.info(msg) + 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) diff --git a/plugin.py b/plugin.py deleted file mode 100644 index 00aa362..0000000 --- a/plugin.py +++ /dev/null @@ -1,809 +0,0 @@ -# -------------------------------------------- -# 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 e17626f..0ceb7b5 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ # Initialization # -------------------- -VERSION_NUMBER = '1.1.2' +VERSION_NUMBER = '1.1.1' # required if you want to run document/test # pip install 'mkdocs-macros-plugin[test]' diff --git a/util.py b/util.py deleted file mode 100644 index c5ec4c4..0000000 --- a/util.py +++ /dev/null @@ -1,245 +0,0 @@ -#!/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