diff --git a/CHANGES.txt b/CHANGES.txt index 93eaeefdae..5991a344d1 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -5,6 +5,9 @@ Features -------- * Support passing ``--poll`` to ``nikola auto`` to better deal with symlink farms. +* Trace template usage when an environment variable ``NIKOLA_TEMPLATES_TRACE`` + is set to any non-empty value. +* Allow configuration of Jinja2 extensions through the theme configuration. Bugfixes -------- diff --git a/docs/theming.rst b/docs/theming.rst index 5beecd1f05..5c550efd03 100644 --- a/docs/theming.rst +++ b/docs/theming.rst @@ -7,7 +7,7 @@ .. author: The Nikola Team :Version: 8.3.0 -:Author: Roberto Alsina +:Author: Roberto Alsina and others .. class:: alert alert-primary float-md-right @@ -104,8 +104,8 @@ with the same name as your theme, and a ``.theme`` extension, eg. .. code:: ini [Theme] - engine = mako - parent = base + engine = jinja + parent = base-jinja author = The Nikola Contributors author_url = https://getnikola.com/ based_on = Bootstrap 3 @@ -120,6 +120,10 @@ with the same name as your theme, and a ``.theme`` extension, eg. [Nikola] bootswatch = True + [jinja] + # Good for investigation, but not recommended to leave active in production: + extensions = jinja2.ext.debug + The following keys are currently supported: * ``Theme`` — contains information about the theme. @@ -130,8 +134,9 @@ The following keys are currently supported: The parent is so you don’t have to create a full theme each time: just create an empty theme, set the parent, and add the bits you want modified. - You **must** define a parent, otherwise many features won’t work due to - missing templates, messages, and assets. + It is strongly recommended you define a parent. If you don't, many features + won’t work due to missing templates, messages, and assets until your home-grown + template is complete. The following settings are recommended: @@ -163,6 +168,13 @@ The following keys are currently supported: * ``ignored_assets`` — comma-separated list of assets to ignore (relative to the ``assets/`` directory, eg. ``css/theme.css``) +* ``jinja`` - This section is ignored unless your theme's engine is ``jinja``. + + * ``extensions`` - comma-separated list of + `jinja2-extensions `_ + that you want to be available when rendering your templates. + + Templates --------- @@ -184,8 +196,16 @@ so ``post.tmpl`` only define the content, and the layout is inherited from ``bas Another concept is theme inheritance. You do not need to duplicate all the default templates in your theme — you can just override the ones you want -changed, and the rest will come from the parent theme. (Every theme needs a -parent.) +changed, and the rest will come from the parent theme. If your theme does not +define a parent, it needs to be complete. It is generally a lot harder to +come up with a complete theme, compared to only changing a few files and using +the rest from a suitable parent theme. + +.. Tip:: + + If you set the environment variable ``NIKOLA_TEMPLATES_TRACE`` to any non-empty value + (``true`` is recommended), Nikola will log template usage, both on output and also + into a file ``templates_log.txt``. Apart from the `built-in templates`_ listed below, you can add other templates for specific pages, which the user can then use in his ``POSTS`` or ``PAGES`` option in @@ -194,11 +214,11 @@ page via the ``template`` metadata, and custom templates can be added in the ``templates/`` folder of your site. If you want to modify (override) a built-in template, use ``nikola theme -c -.tmpl``. This command will copy the specified template file to the -``templates/`` directory of your currently used theme. +.tmpl``. This command will copy the specified template file from the +parent theme to the ``templates/`` directory of your currently used theme. Keep in mind that your theme is *yours*, so you can require whatever data you -want (eg. you may depend on specific custom ``GLOBAL_CONTEXT`` variables, or +want (e.g., you may depend on specific custom ``GLOBAL_CONTEXT`` variables, or post meta attributes). You don’t need to keep the same theme structure as the default themes do (although many of those names are hardcoded). Inheriting from at least ``base`` (or ``base-jinja``) is heavily recommended, but not strictly diff --git a/nikola/__init__.py b/nikola/__init__.py index edf1c93bc2..f40dba78a4 100644 --- a/nikola/__init__.py +++ b/nikola/__init__.py @@ -29,8 +29,15 @@ import os import sys +# The current Nikola version: __version__ = '8.3.0' +# A flag whether logging should emmit debug information: DEBUG = bool(os.getenv('NIKOLA_DEBUG')) +# A flag whether special templates trace logging should be generated: +TEMPLATES_TRACE = bool(os.getenv('NIKOLA_TEMPLATES_TRACE')) +# When this flag is set, fewer exceptions are handled internally; +# instead they are left unhandled for the run time system to deal with them, +# which typically leads to the stack traces being exposed. SHOW_TRACEBACKS = bool(os.getenv('NIKOLA_SHOW_TRACEBACKS')) if sys.version_info[0] == 2: diff --git a/nikola/log.py b/nikola/log.py index f998427f0a..9848477b53 100644 --- a/nikola/log.py +++ b/nikola/log.py @@ -30,7 +30,7 @@ import logging import warnings -from nikola import DEBUG +from nikola import DEBUG, TEMPLATES_TRACE __all__ = ( "get_logger", @@ -86,6 +86,10 @@ class LoggingMode(enum.Enum): QUIET = 2 +_LOGGING_FMT = "[%(asctime)s] %(levelname)s: %(name)s: %(message)s" +_LOGGING_DATEFMT = "%Y-%m-%d %H:%M:%S" + + def configure_logging(logging_mode: LoggingMode = LoggingMode.NORMAL) -> None: """Configure logging for Nikola. @@ -101,12 +105,7 @@ def configure_logging(logging_mode: LoggingMode = LoggingMode.NORMAL) -> None: return handler = logging.StreamHandler() - handler.setFormatter( - ColorfulFormatter( - fmt="[%(asctime)s] %(levelname)s: %(name)s: %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - ) + handler.setFormatter(ColorfulFormatter(fmt=_LOGGING_FMT, datefmt=_LOGGING_DATEFMT)) handlers = [handler] if logging_mode == LoggingMode.STRICT: @@ -137,6 +136,39 @@ def get_logger(name: str, handlers=None) -> logging.Logger: LOGGER = get_logger("Nikola") +TEMPLATES_LOGGER = get_logger("nikola.templates") + + +def init_template_trace_logging(filename: str) -> None: + """Initialize the tracing of the template system. + + This tells a theme designer which templates are being exercised + and for which output files, and, if applicable, input files. + + As there is lots of other stuff happening on the normal output stream, + this info is also written to a log file. + """ + TEMPLATES_LOGGER.level = logging.DEBUG + formatter = logging.Formatter( + fmt=_LOGGING_FMT, + datefmt=_LOGGING_DATEFMT, + ) + shandler = logging.StreamHandler() + shandler.setFormatter(formatter) + shandler.setLevel(logging.DEBUG) + + fhandler = logging.FileHandler(filename, encoding="UTF-8") + fhandler.setFormatter(formatter) + fhandler.setLevel(logging.DEBUG) + + TEMPLATES_LOGGER.handlers = [shandler, fhandler] + TEMPLATES_LOGGER.propagate = False + + TEMPLATES_LOGGER.info("Template usage being traced to file %s", filename) + + +if TEMPLATES_TRACE: + init_template_trace_logging("templates_log.txt") # Push warnings to logging diff --git a/nikola/nikola.py b/nikola/nikola.py index 124b030054..83ca9df1aa 100644 --- a/nikola/nikola.py +++ b/nikola/nikola.py @@ -36,6 +36,7 @@ import pathlib import sys import typing +from typing import Any, Dict, Iterable, List, Optional, Set import mimetypes from collections import defaultdict from copy import copy @@ -373,7 +374,7 @@ class Nikola(object): plugin_manager: PluginManager _template_system: TemplateSystem - def __init__(self, **config): + def __init__(self, **config) -> None: """Initialize proper environment for running tasks.""" # Register our own path handlers self.path_handlers = { @@ -395,7 +396,7 @@ def __init__(self, **config): self.timeline = [] self.pages = [] self._scanned = False - self._template_system = None + self._template_system: Optional[TemplateSystem] = None self._THEMES = None self._MESSAGES = None self.filters = {} @@ -996,13 +997,13 @@ def __init__(self, **config): # WebP files have no official MIME type yet, but we need to recognize them (Issue #3671) mimetypes.add_type('image/webp', '.webp') - def _filter_duplicate_plugins(self, plugin_list: typing.Iterable[PluginCandidate]): + def _filter_duplicate_plugins(self, plugin_list: Iterable[PluginCandidate]): """Find repeated plugins and discard the less local copy.""" def plugin_position_in_places(plugin: PluginInfo): # plugin here is a tuple: # (path to the .plugin file, path to plugin module w/o .py, plugin metadata) + place: pathlib.Path for i, place in enumerate(self._plugin_places): - place: pathlib.Path try: # Path.is_relative_to backport plugin.source_dir.relative_to(place) @@ -1025,7 +1026,7 @@ def plugin_position_in_places(plugin: PluginInfo): result.append(plugins[-1]) return result - def init_plugins(self, commands_only=False, load_all=False): + def init_plugins(self, commands_only=False, load_all=False) -> None: """Load plugins as needed.""" extra_plugins_dirs = self.config['EXTRA_PLUGINS_DIRS'] self._loading_commands_only = commands_only @@ -1086,9 +1087,9 @@ def init_plugins(self, commands_only=False, load_all=False): # Search for compiler plugins which we disabled but shouldn't have self._activate_plugins_of_category("PostScanner") if not load_all: - file_extensions = set() + file_extensions: Set[str] = set() + post_scanner: PostScanner for post_scanner in [p.plugin_object for p in self.plugin_manager.get_plugins_of_category('PostScanner')]: - post_scanner: PostScanner exts = post_scanner.supported_extensions() if exts is not None: file_extensions.update(exts) @@ -1126,8 +1127,8 @@ def init_plugins(self, commands_only=False, load_all=False): self._activate_plugins_of_category("Taxonomy") self.taxonomy_plugins = {} + taxonomy: Taxonomy for taxonomy in [p.plugin_object for p in self.plugin_manager.get_plugins_of_category('Taxonomy')]: - taxonomy: Taxonomy if not taxonomy.is_enabled(): continue if taxonomy.classification_name in self.taxonomy_plugins: @@ -1322,7 +1323,7 @@ def _activate_plugin(self, plugin_info: PluginInfo) -> None: if candidate.exists() and candidate.is_dir(): self.template_system.inject_directory(str(candidate)) - def _activate_plugins_of_category(self, category) -> typing.List[PluginInfo]: + def _activate_plugins_of_category(self, category) -> List[PluginInfo]: """Activate all the plugins of a given category and return them.""" # this code duplicated in tests/base.py plugins = [] @@ -1390,13 +1391,15 @@ def _get_global_context(self): def _get_template_system(self): if self._template_system is None: # Load template plugin - template_sys_name = utils.get_template_engine(self.THEMES) + template_sys_name, template_sys_user_config = utils.get_template_engine(self.THEMES) pi = self.plugin_manager.get_plugin_by_name(template_sys_name, "TemplateSystem") if pi is None: sys.stderr.write("Error loading {0} template system " "plugin\n".format(template_sys_name)) sys.exit(1) self._template_system = typing.cast(TemplateSystem, pi.plugin_object) + if template_sys_user_config is not None: + self._template_system.user_configuration(template_sys_user_config) lookup_dirs = ['templates'] + [os.path.join(utils.get_theme_path(name), "templates") for name in self.THEMES] self._template_system.set_directories(lookup_dirs, @@ -1444,7 +1447,7 @@ def get_compiler(self, source_name): return compiler - def render_template(self, template_name, output_name, context, url_type=None, is_fragment=False): + def render_template(self, template_name: str, output_name: str, context, url_type=None, is_fragment=False): """Render a template with the global context. If ``output_name`` is None, will return a string and all URL @@ -1459,7 +1462,11 @@ def render_template(self, template_name, output_name, context, url_type=None, is If ``is_fragment`` is set to ``True``, a HTML fragment will be rendered and not a whole HTML document. """ - local_context = {} + if "post" in context and context["post"] is not None: + utils.TEMPLATES_LOGGER.debug("For %s, template %s builds %s", context["post"].source_path, template_name, output_name) + else: + utils.TEMPLATES_LOGGER.debug("Template %s builds %s", template_name, output_name) + local_context: Dict[str, Any] = {} local_context["template_name"] = template_name local_context.update(self.GLOBAL_CONTEXT) local_context.update(context) @@ -1695,7 +1702,7 @@ def _register_templated_shortcodes(self): builtin_sc_dir = utils.pkg_resources_path( 'nikola', - os.path.join('data', 'shortcodes', utils.get_template_engine(self.THEMES))) + os.path.join('data', 'shortcodes', utils.get_template_engine(self.THEMES)[0])) for sc_dir in [builtin_sc_dir, 'shortcodes']: if not os.path.isdir(sc_dir): diff --git a/nikola/plugin_categories.py b/nikola/plugin_categories.py index b9eee3af00..31b7c43ab4 100644 --- a/nikola/plugin_categories.py +++ b/nikola/plugin_categories.py @@ -29,16 +29,15 @@ import io import logging import os -import typing + +from typing import Callable, Dict, Iterable, List, Mapping, Optional, Tuple import doit from doit.cmd_base import Command as DoitCommand from .utils import LOGGER, first_line, get_logger, req_missing -if typing.TYPE_CHECKING: - import nikola - import nikola.post +from nikola.post import Post __all__ = ( 'Command', @@ -88,11 +87,11 @@ def register_auto_watched_folder(self, folder: str) -> None: class PostScanner(BasePlugin): """The scan method of these plugins is called by Nikola.scan_posts.""" - def scan(self) -> 'typing.List[nikola.post.Post]': + def scan(self) -> List[Post]: """Create a list of posts from some source. Returns a list of Post objects.""" raise NotImplementedError() - def supported_extensions(self) -> 'typing.Optional[typing.List]': + def supported_extensions(self) -> Optional[List[str]]: """Return a list of supported file extensions, or None if such a list isn't known beforehand.""" return None @@ -171,11 +170,11 @@ class BaseTask(BasePlugin): # the others have to be specifie in the command line. is_default = True - def gen_tasks(self) -> 'typing.List[dict]': + def gen_tasks(self) -> List[dict]: """Generate tasks.""" raise NotImplementedError() - def group_task(self) -> dict: + def group_task(self) -> Dict[str, Optional[str]]: """Return dict for group task.""" return { 'basename': self.name, @@ -201,10 +200,14 @@ class TemplateSystem(BasePlugin): name = "dummy_templates" - def set_directories(self, directories: 'typing.List[str]', cache_folder: str): + def set_directories(self, directories: List[str], cache_folder: str) -> None: """Set the list of folders where templates are located and cache.""" raise NotImplementedError() + def user_configuration(self, user_config: Mapping[str, str]) -> None: + """Accept user configuration from the theme configuration file.""" + raise NotImplementedError() + def template_deps(self, template_name: str, context=None): """Return filenames which are dependencies for a template.""" raise NotImplementedError() @@ -217,7 +220,7 @@ def get_string_deps(self, text: str, context=None): """Find dependencies for a template string.""" raise NotImplementedError() - def render_template(self, template_name: str, output_name: str, context: 'typing.Dict[str, str]'): + def render_template(self, template_name: str, output_name: str, context: Dict[str, str]) -> str: """Render template to a file using context. This must save the data to output_name *and* return it @@ -225,7 +228,7 @@ def render_template(self, template_name: str, output_name: str, context: 'typing """ raise NotImplementedError() - def render_template_to_string(self, template: str, context: 'typing.Dict[str, str]') -> str: + def render_template_to_string(self, template: str, context: Dict[str, str]) -> str: """Render template to a string using context.""" raise NotImplementedError() @@ -270,11 +273,11 @@ class PageCompiler(BasePlugin): } config_dependencies = [] - def get_dep_filename(self, post: 'nikola.post.Post', lang: str) -> str: + def get_dep_filename(self, post: Post, lang: str) -> str: """Return the .dep file's name for the given post and language.""" return post.translated_base_path(lang) + '.dep' - def _read_extra_deps(self, post: 'nikola.post.Post', lang: str) -> 'typing.List[str]': + def _read_extra_deps(self, post: Post, lang: str) -> List[str]: """Read contents of .dep file and return them as a list.""" dep_path = self.get_dep_filename(post, lang) if os.path.isfile(dep_path): @@ -283,9 +286,9 @@ def _read_extra_deps(self, post: 'nikola.post.Post', lang: str) -> 'typing.List[ return deps return [] - def register_extra_dependencies(self, post: 'nikola.post.Post'): + def register_extra_dependencies(self, post: Post): """Add dependency to post object to check .dep file.""" - def create_lambda(lang: str) -> 'typing.Callable': + def create_lambda(lang: str) -> Callable: # We create a lambda like this so we can pass `lang` to it, because if we didn’t # add that function, `lang` would always be the last language in TRANSLATIONS. # (See https://docs.python-guide.org/writing/gotchas/#late-binding-closures) @@ -294,7 +297,7 @@ def create_lambda(lang: str) -> 'typing.Callable': for lang in self.site.config['TRANSLATIONS']: post.add_dependency(create_lambda(lang), 'fragment', lang=lang) - def get_extra_targets(self, post: 'nikola.post.Post', lang: str, dest: str) -> 'typing.List[str]': + def get_extra_targets(self, post: Post, lang: str, dest: str) -> List[str]: """Return a list of extra targets for the render_posts task when compiling the post for the specified language.""" if self.use_dep_file: return [self.get_dep_filename(post, lang)] @@ -321,11 +324,11 @@ def extension(self) -> str: """Return the preferred extension for the output of this compiler.""" return ".html" - def read_metadata(self, post: 'nikola.post.Post', lang=None) -> 'typing.Dict[str, str]': + def read_metadata(self, post: Post, lang=None) -> Dict[str, str]: """Read the metadata from a post, and return a metadata dict.""" return {} - def split_metadata(self, data: str, post=None, lang=None) -> (str, str): + def split_metadata(self, data: str, post=None, lang=None) -> Tuple[str, str]: """Split data from metadata in the raw post content.""" if lang and post: extractor = post.used_extractor[lang] @@ -396,14 +399,14 @@ class MetadataExtractor(BasePlugin): # Whether or not the extractor supports writing metadata. supports_write = False - def _extract_metadata_from_text(self, source_text: str) -> 'typing.Dict[str, str]': + def _extract_metadata_from_text(self, source_text: str) -> Dict[str, str]: """Extract metadata from text.""" raise NotImplementedError() - def split_metadata_from_text(self, source_text: str) -> (str, str): + def split_metadata_from_text(self, source_text: str) -> Tuple[str, str]: """Split text into metadata and content (both strings).""" if self.split_metadata_re is None: - return source_text + return "", source_text else: split_result = self.split_metadata_re.split(source_text.lstrip(), maxsplit=1) if len(split_result) == 1: @@ -412,19 +415,19 @@ def split_metadata_from_text(self, source_text: str) -> (str, str): # Necessary? return split_result[0], split_result[-1] - def extract_text(self, source_text: str) -> 'typing.Dict[str, str]': + def extract_text(self, source_text: str) -> Dict[str, str]: """Split file, return metadata and the content.""" + # TODO: The name and interface of this method is a mess and needs to be cleaned up. split = self.split_metadata_from_text(source_text) - if not split: + if len(split[0]) == 0: return {} - meta = self._extract_metadata_from_text(split[0]) - return meta + return self._extract_metadata_from_text(split[0]) - def extract_filename(self, filename: str, lang: str) -> 'typing.Dict[str, str]': + def extract_filename(self, filename: str, lang: str) -> Dict[str, str]: """Extract metadata from filename.""" return {} - def write_metadata(self, metadata: 'typing.Dict[str, str]', comment_wrap=False) -> str: + def write_metadata(self, metadata: Dict[str, str], comment_wrap=False) -> str: """Write metadata in this extractor’s format. ``comment_wrap`` is either True, False, or a 2-tuple of comments to use for wrapping, if necessary. @@ -715,18 +718,18 @@ def is_enabled(self, lang=None) -> bool: """ return True - def get_implicit_classifications(self, lang: str) -> 'typing.List[str]': + def get_implicit_classifications(self, lang: str) -> List[str]: """Return a list of classification strings which should always appear in posts_per_classification.""" return [] - def classify(self, post: 'nikola.post.Post', lang: str) -> 'typing.Iterable[str]': + def classify(self, post: Post, lang: str) -> Iterable[str]: """Classify the given post for the given language. Must return a list or tuple of strings. """ raise NotImplementedError() - def sort_posts(self, posts: 'typing.List[nikola.post.Post]', classification: str, lang: str): + def sort_posts(self, posts: List[Post], classification: str, lang: str): """Sort the given list of posts. Allows the plugin to order the posts per classification as it wants. @@ -735,7 +738,7 @@ def sort_posts(self, posts: 'typing.List[nikola.post.Post]', classification: str """ pass - def sort_classifications(self, classifications: 'typing.List[str]', lang: str, level=None): + def sort_classifications(self, classifications: List[str], lang: str, level=None): """Sort the given list of classification strings. Allows the plugin to order the classifications as it wants. The @@ -809,7 +812,7 @@ def get_path(self, classification: str, lang: str, dest_type='page') -> str: """ raise NotImplementedError() - def extract_hierarchy(self, classification: str) -> 'typing.List[str]': + def extract_hierarchy(self, classification: str) -> List[str]: """Given a classification, return a list of parts in the hierarchy. For non-hierarchical taxonomies, it usually suffices to return @@ -817,7 +820,7 @@ def extract_hierarchy(self, classification: str) -> 'typing.List[str]': """ return [classification] - def recombine_classification_from_hierarchy(self, hierarchy: 'typing.List[str]') -> str: + def recombine_classification_from_hierarchy(self, hierarchy: List[str]) -> str: """Given a list of parts in the hierarchy, return the classification string. For non-hierarchical taxonomies, it usually suffices to return hierarchy[0]. @@ -834,7 +837,7 @@ def provide_overview_context_and_uptodate(self, lang: str) -> str: """ raise NotImplementedError() - def provide_context_and_uptodate(self, classification: str, lang: str, node=None) -> 'typing.Tuple[typing.Dict]': + def provide_context_and_uptodate(self, classification: str, lang: str, node=None) -> Tuple[Dict, Dict]: """Provide data for the context and the uptodate list for the list of the given classification. Must return a tuple of two dicts. The first is merged into the page's context, @@ -847,19 +850,19 @@ def provide_context_and_uptodate(self, classification: str, lang: str, node=None """ raise NotImplementedError() - def should_generate_classification_page(self, classification: str, post_list: 'typing.List[nikola.post.Post]', lang: str) -> bool: + def should_generate_classification_page(self, classification: str, post_list: List[Post], lang: str) -> bool: """Only generates list of posts for classification if this function returns True.""" return True - def should_generate_atom_for_classification_page(self, classification: str, post_list: 'typing.List[nikola.post.Post]', lang: str) -> bool: + def should_generate_atom_for_classification_page(self, classification: str, post_list: List[Post], lang: str) -> bool: """Only generates Atom feed for list of posts for classification if this function returns True.""" return self.should_generate_classification_page(classification, post_list, lang) - def should_generate_rss_for_classification_page(self, classification: str, post_list: 'typing.List[nikola.post.Post]', lang: str) -> bool: + def should_generate_rss_for_classification_page(self, classification: str, post_list: List[Post], lang: str) -> bool: """Only generates RSS feed for list of posts for classification if this function returns True.""" return self.should_generate_classification_page(classification, post_list, lang) - def postprocess_posts_per_classification(self, posts_per_classification_per_language: 'typing.List[nikola.post.Post]', flat_hierarchy_per_lang=None, hierarchy_lookup_per_lang=None) -> 'typing.List[nikola.post.Post]': + def postprocess_posts_per_classification(self, posts_per_classification_per_language: List[Post], flat_hierarchy_per_lang=None, hierarchy_lookup_per_lang=None) -> None: """Rearrange, modify or otherwise use the list of posts per classification and per language. For compatibility reasons, the list could be stored somewhere else as well. @@ -871,7 +874,7 @@ def postprocess_posts_per_classification(self, posts_per_classification_per_lang """ pass - def get_other_language_variants(self, classification: str, lang: str, classifications_per_language: 'typing.List[str]') -> 'typing.List[str]': + def get_other_language_variants(self, classification: str, lang: str, classifications_per_language: List[str]) -> List[str]: """Return a list of variants of the same classification in other languages. Given a `classification` in a language `lang`, return a list of pairs diff --git a/nikola/plugin_manager.py b/nikola/plugin_manager.py index ae93205bcf..7dbf2140e7 100644 --- a/nikola/plugin_manager.py +++ b/nikola/plugin_manager.py @@ -35,13 +35,12 @@ from collections import deque from dataclasses import dataclass from pathlib import Path -from typing import Dict, List, Optional, Type, TYPE_CHECKING, Set +from typing import Dict, Iterable, List, Optional, Type, Set from .plugin_categories import BasePlugin, CATEGORIES from .utils import get_logger -if TYPE_CHECKING: - import logging +import logging LEGACY_PLUGIN_NAMES: Dict[str, str] = { "Compiler": "PageCompiler", @@ -157,7 +156,7 @@ def locate_plugins(self) -> List[PluginCandidate]: ) return self.candidates - def load_plugins(self, candidates: List[PluginCandidate]) -> None: + def load_plugins(self, candidates: Iterable[PluginCandidate]) -> None: """Load selected candidate plugins.""" plugins_root = Path(__file__).parent.parent diff --git a/nikola/plugins/command/theme.py b/nikola/plugins/command/theme.py index 388001fc20..5a9059fa25 100644 --- a/nikola/plugins/command/theme.py +++ b/nikola/plugins/command/theme.py @@ -334,7 +334,7 @@ def new_theme(self, name, engine, parent, create_legacy_meta=False): LOGGER.info("Created directory {0}".format(base)) # Check if engine and parent match - parent_engine = utils.get_template_engine(utils.get_theme_chain(parent, self.site.themes_dirs)) + parent_engine = utils.get_template_engine(utils.get_theme_chain(parent, self.site.themes_dirs))[0] if parent_engine != engine: LOGGER.error("Cannot use engine {0} because parent theme '{1}' uses {2}".format(engine, parent, parent_engine)) diff --git a/nikola/plugins/task/archive.py b/nikola/plugins/task/archive.py index 44849fa007..f4eaa5e2cb 100644 --- a/nikola/plugins/task/archive.py +++ b/nikola/plugins/task/archive.py @@ -209,7 +209,7 @@ def provide_context_and_uptodate(self, classification, lang, node=None): kw.update(context) return context, kw - def postprocess_posts_per_classification(self, posts_per_classification_per_language, flat_hierarchy_per_lang=None, hierarchy_lookup_per_lang=None): + def postprocess_posts_per_classification(self, posts_per_classification_per_language, flat_hierarchy_per_lang=None, hierarchy_lookup_per_lang=None) -> None: """Rearrange, modify or otherwise use the list of posts per classification and per language.""" # Build a lookup table for archive navigation, if we’ll need one. if self.site.config['CREATE_ARCHIVE_NAVIGATION']: @@ -228,7 +228,7 @@ def postprocess_posts_per_classification(self, posts_per_classification_per_lang for k, v in self.archive_navigation[lang].items(): self.archive_navigation[lang][k] = natsort.natsorted(v, alg=natsort.ns.F | natsort.ns.IC) - return super().postprocess_posts_per_classification(posts_per_classification_per_language, flat_hierarchy_per_lang, hierarchy_lookup_per_lang) + super().postprocess_posts_per_classification(posts_per_classification_per_language, flat_hierarchy_per_lang, hierarchy_lookup_per_lang) def should_generate_classification_page(self, classification, post_list, lang): """Only generates list of posts for classification if this function returns True.""" diff --git a/nikola/plugins/template/jinja.py b/nikola/plugins/template/jinja.py index e0ddf4aae7..ad2158a8f0 100644 --- a/nikola/plugins/template/jinja.py +++ b/nikola/plugins/template/jinja.py @@ -29,6 +29,7 @@ import io import json import os +from typing import List, Mapping, Optional from nikola.plugin_categories import TemplateSystem from nikola.utils import makedirs, req_missing, slugify, sort_posts, _smartjoin_filter @@ -45,15 +46,35 @@ class JinjaTemplates(TemplateSystem): """Support for Jinja2 templates.""" name = "jinja" - lookup = None + if jinja2 is None: + lookup = None + else: + lookup: Optional[jinja2.Environment] = None dependency_cache = {} per_file_cache = {} + _user_configured_jina_extensions: List[str] = [] def __init__(self): """Initialize Jinja2 environment with extended set of filters.""" if jinja2 is None: return + def user_configuration(self, user_config: Mapping[str, str]) -> None: + """Accept user configuration from the theme configuration file.""" + supported_config_keys = set(["extensions",]) + for key in user_config.keys(): + if key not in supported_config_keys: + raise RuntimeError(f'Configuration key "{key}" found in theme init file is not supported for jinja template engine.') + + if "extensions" in user_config: + self._user_configured_jina_extensions = [ext for ext in (e.strip() for e in user_config["extensions"].split(",")) if ext] + else: + self._user_configured_jina_extensions = [] + + if self.lookup: + for wanted_extension in self._user_configured_jina_extensions: + self.lookup.add_extension(wanted_extension) + def set_directories(self, directories, cache_folder): """Create a new template lookup with set directories.""" if jinja2 is None: @@ -62,6 +83,8 @@ def set_directories(self, directories, cache_folder): makedirs(cache_folder) cache = jinja2.FileSystemBytecodeCache(cache_folder) self.lookup = jinja2.Environment(bytecode_cache=cache) + for wanted_extension in self._user_configured_jina_extensions: + self.lookup.add_extension(wanted_extension) self.lookup.trim_blocks = True self.lookup.lstrip_blocks = True self.lookup.filters['tojson'] = json.dumps diff --git a/nikola/utils.py b/nikola/utils.py index 71c2119fd9..69b2c7fde9 100644 --- a/nikola/utils.py +++ b/nikola/utils.py @@ -40,9 +40,8 @@ import subprocess import sys import threading -import typing from collections import defaultdict, OrderedDict -from collections.abc import Callable, Iterable +from collections.abc import Callable from html import unescape as html_unescape from importlib import reload as _reload from unicodedata import normalize as unicodenormalize @@ -61,12 +60,12 @@ from doit import tools from doit.cmdparse import CmdParse from nikola.packages.pygments_better_html import BetterHtmlFormatter -from typing import List +from typing import Any, Dict, Iterable, List, Mapping, Match, Optional, Tuple, Union from unidecode import unidecode # Renames from nikola import DEBUG # NOQA -from .log import LOGGER, get_logger # NOQA +from .log import LOGGER, TEMPLATES_LOGGER, get_logger # NOQA from .hierarchy_utils import TreeNode, clone_treenode, flatten_tree_structure, sort_classifications from .hierarchy_utils import join_hierarchical_category_path, parse_escaped_hierarchical_category_name @@ -589,7 +588,7 @@ def pkg_resources_path(package, resource): return str(resources.files(package).joinpath(resource)) -def get_theme_path_real(theme, themes_dirs): +def get_theme_path_real(theme, themes_dirs) -> str: """Return the path where the given theme's files are located. Looks in ./themes and in the place where themes go when installed. @@ -609,7 +608,7 @@ def get_theme_path(theme): return theme -def parse_theme_meta(theme_dir): +def parse_theme_meta(theme_dir) -> Optional[configparser.ConfigParser]: """Parse a .theme meta file.""" cp = configparser.ConfigParser() # The `or` case is in case theme_dir ends with a trailing slash @@ -619,25 +618,31 @@ def parse_theme_meta(theme_dir): return cp if cp.has_section('Theme') else None -def get_template_engine(themes): - """Get template engine used by a given theme.""" +def get_template_engine(themes) -> Tuple[str, Optional[Mapping[str, str]]]: + """Get template engine used by a given theme, plus any config info for that engine if pertinent.""" for theme_name in themes: meta = parse_theme_meta(theme_name) if meta: e = meta.get('Theme', 'engine', fallback=None) if e: - return e + # Maybe we have a configuration section like "Jinja" or "Mako" + # that can be used to configure the template engine further: + if e in meta: + config = meta[e] + return e, config + else: + return e, None else: # Theme still uses old-style parent/engine files engine_path = os.path.join(theme_name, 'engine') if os.path.isfile(engine_path): with open(engine_path) as fd: - return fd.readlines()[0].strip() + return fd.readlines()[0].strip(), None # default - return 'mako' + return 'mako', None -def get_parent_theme_name(theme_name, themes_dirs=None): +def get_parent_theme_name(theme_name, themes_dirs=None) -> Optional[str]: """Get name of parent theme.""" meta = parse_theme_meta(theme_name) if meta: @@ -657,7 +662,7 @@ def get_parent_theme_name(theme_name, themes_dirs=None): return None -def get_theme_chain(theme, themes_dirs): +def get_theme_chain(theme, themes_dirs) -> List[str]: """Create the full theme inheritance chain including paths.""" themes = [get_theme_path_real(theme, themes_dirs)] @@ -1195,7 +1200,7 @@ class LocaleBorg(object): in_string_formatter = None @classmethod - def initialize(cls, locales: 'typing.Dict[str, str]', initial_lang: str): + def initialize(cls, locales: Dict[str, str], initial_lang: str): """Initialize LocaleBorg. locales: dict with custom locale name overrides. @@ -1248,8 +1253,8 @@ def set_locale(self, lang: str) -> str: return '' def formatted_date(self, date_format: 'str', - date: 'typing.Union[datetime.date, datetime.datetime]', - lang: 'typing.Optional[str]' = None) -> str: + date: Union[datetime.date, datetime.datetime], + lang: Optional[str] = None) -> str: """Return the formatted date/datetime as a string.""" if lang is None: lang = self.current_lang @@ -1268,7 +1273,7 @@ def formatted_date(self, date_format: 'str', else: return format_datetime(date, date_format, locale=locale) - def format_date_in_string(self, message: str, date: datetime.date, lang: 'typing.Optional[str]' = None) -> str: + def format_date_in_string(self, message: str, date: datetime.date, lang: Optional[str] = None) -> str: """Format date inside a string (message). Accepted modes: month, month_year, month_day_year. @@ -1284,7 +1289,7 @@ def format_date_in_string(self, message: str, date: datetime.date, lang: 'typing lang = self.current_lang locale = self.locales.get(lang, lang) - def date_formatter(match: typing.Match) -> str: + def date_formatter(match: Match) -> str: """Format a date as requested.""" mode, custom_format = match.groups() if LocaleBorg.in_string_formatter is not None: @@ -1938,8 +1943,8 @@ def sort_posts(posts, *keys): return posts -def smartjoin(join_char: str, string_or_iterable) -> str: - """Join string_or_iterable with join_char if it is iterable; otherwise converts it to string. +def smartjoin(join_char: str, string_or_iterable: Union[None, str, bytes, Iterable[Any]]) -> str: + """Join string_or_iterable with join_char if it is iterable; otherwise convert it to string. >>> smartjoin('; ', 'foo, bar') 'foo, bar' @@ -1947,10 +1952,10 @@ def smartjoin(join_char: str, string_or_iterable) -> str: 'foo; bar' >>> smartjoin(' to ', ['count', 42]) 'count to 42' + + The treatment of bytes (calling str(string_or_iterable)) is somewhat dubious. Is this needed? """ - if isinstance(string_or_iterable, (str, bytes)): - return string_or_iterable - elif isinstance(string_or_iterable, Iterable): + if isinstance(string_or_iterable, Iterable): return join_char.join([str(e) for e in string_or_iterable]) else: return str(string_or_iterable)