From b268469b1eea3aa7a27257fd367bd78834210d5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BCger?= Date: Tue, 20 Feb 2024 14:27:15 +0100 Subject: [PATCH 1/5] Usage improvements for theme / template developers. New features: - Trace template usage if env variable NIKOLA_TEMPLATES_TRACE is set. - Enable Jinja2 extensions via theme configuration. - Clarify template documentation: A parent template is not strictly needed. Additional code improvements helpful for those developing templates who actually read the code: - Added several typing hints and improved others. - A few code comments were added or improved. From fc7a75f687323d9f3358bda2b77acd317e445e65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BCger?= Date: Tue, 20 Feb 2024 14:37:22 +0100 Subject: [PATCH 2/5] Templates trace logging introduced. --- CHANGES.txt | 2 ++ docs/theming.rst | 25 +++++++++++++++++-------- nikola/__init__.py | 7 +++++++ nikola/log.py | 43 ++++++++++++++++++++++++++++++++++++++++--- nikola/nikola.py | 8 ++++++-- nikola/utils.py | 2 +- 6 files changed, 73 insertions(+), 14 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 93eaeefdae..f310bbdb8a 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -5,6 +5,8 @@ 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. Bugfixes -------- diff --git a/docs/theming.rst b/docs/theming.rst index 5beecd1f05..0c2f5f7583 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 @@ -130,8 +130,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: @@ -184,8 +185,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 +203,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..91db4ae554 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. @@ -103,8 +107,8 @@ def configure_logging(logging_mode: LoggingMode = LoggingMode.NORMAL) -> None: handler = logging.StreamHandler() handler.setFormatter( ColorfulFormatter( - fmt="[%(asctime)s] %(levelname)s: %(name)s: %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", + fmt=_LOGGING_FMT, + datefmt=_LOGGING_DATEFMT, ) ) @@ -137,6 +141,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..5609b408df 100644 --- a/nikola/nikola.py +++ b/nikola/nikola.py @@ -395,7 +395,7 @@ def __init__(self, **config): self.timeline = [] self.pages = [] self._scanned = False - self._template_system = None + self._template_system: typing.Optional[TemplateSystem] = None self._THEMES = None self._MESSAGES = None self.filters = {} @@ -1459,7 +1459,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: typing.Dict[str, typing.Any] = {} local_context["template_name"] = template_name local_context.update(self.GLOBAL_CONTEXT) local_context.update(context) diff --git a/nikola/utils.py b/nikola/utils.py index 71c2119fd9..6f6811b1bb 100644 --- a/nikola/utils.py +++ b/nikola/utils.py @@ -66,7 +66,7 @@ # 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 From 893e4c0df58ad36b583265600b8bf51118148e2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BCger?= Date: Tue, 20 Feb 2024 14:41:24 +0100 Subject: [PATCH 3/5] Allow user-defined template engine configuration via the themes configuration. Currently only implemented configuration is for the Jinja templating engine, it is now possible to configure Jinja extensions. Also some improvements to make the template handling code better and more readable; mostly typing. --- CHANGES.txt | 1 + docs/theming.rst | 15 +++++- nikola/log.py | 9 +--- nikola/nikola.py | 29 +++++++----- nikola/plugin_categories.py | 81 +++++++++++++++++--------------- nikola/plugin_manager.py | 7 ++- nikola/plugins/command/theme.py | 2 +- nikola/plugins/task/archive.py | 4 +- nikola/plugins/template/jinja.py | 24 +++++++++- nikola/utils.py | 47 +++++++++--------- 10 files changed, 129 insertions(+), 90 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index f310bbdb8a..5991a344d1 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -7,6 +7,7 @@ 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 0c2f5f7583..5c550efd03 100644 --- a/docs/theming.rst +++ b/docs/theming.rst @@ -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. @@ -164,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 --------- diff --git a/nikola/log.py b/nikola/log.py index 91db4ae554..9848477b53 100644 --- a/nikola/log.py +++ b/nikola/log.py @@ -105,12 +105,7 @@ def configure_logging(logging_mode: LoggingMode = LoggingMode.NORMAL) -> None: return handler = logging.StreamHandler() - handler.setFormatter( - ColorfulFormatter( - fmt=_LOGGING_FMT, - datefmt=_LOGGING_DATEFMT, - ) - ) + handler.setFormatter(ColorfulFormatter(fmt=_LOGGING_FMT, datefmt=_LOGGING_DATEFMT)) handlers = [handler] if logging_mode == LoggingMode.STRICT: @@ -152,7 +147,7 @@ def init_template_trace_logging(filename: str) -> None: 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, diff --git a/nikola/nikola.py b/nikola/nikola.py index 5609b408df..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: typing.Optional[TemplateSystem] = 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 @@ -1463,7 +1466,7 @@ def render_template(self, template_name, output_name, context, url_type=None, is 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: typing.Dict[str, typing.Any] = {} + local_context: Dict[str, Any] = {} local_context["template_name"] = template_name local_context.update(self.GLOBAL_CONTEXT) local_context.update(context) @@ -1699,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..a08ae44b4a 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,34 @@ 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: + 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 +82,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 6f6811b1bb..d67e136a85 100644 --- a/nikola/utils.py +++ b/nikola/utils.py @@ -40,7 +40,6 @@ import subprocess import sys import threading -import typing from collections import defaultdict, OrderedDict from collections.abc import Callable, Iterable from html import unescape as html_unescape @@ -61,7 +60,7 @@ 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, List, Mapping, Match, Optional, Tuple, Union from unidecode import unidecode # Renames @@ -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) From 074f59d86ca0757e617e52ffeb5fe91bd5955e1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BCger?= Date: Tue, 20 Feb 2024 15:05:18 +0100 Subject: [PATCH 4/5] Adding a docstring as required by the automated test. --- nikola/plugins/template/jinja.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nikola/plugins/template/jinja.py b/nikola/plugins/template/jinja.py index a08ae44b4a..ad2158a8f0 100644 --- a/nikola/plugins/template/jinja.py +++ b/nikola/plugins/template/jinja.py @@ -60,6 +60,7 @@ def __init__(self): 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: From b46b1211128f21c3b56fab1b61e547ecd71d8c27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BCger?= Date: Tue, 20 Feb 2024 16:29:51 +0100 Subject: [PATCH 5/5] Backporting to Python 3.8. --- nikola/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nikola/utils.py b/nikola/utils.py index d67e136a85..69b2c7fde9 100644 --- a/nikola/utils.py +++ b/nikola/utils.py @@ -41,7 +41,7 @@ import sys import threading 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 @@ -60,7 +60,7 @@ from doit import tools from doit.cmdparse import CmdParse from nikola.packages.pygments_better_html import BetterHtmlFormatter -from typing import Any, Dict, List, Mapping, Match, Optional, Tuple, Union +from typing import Any, Dict, Iterable, List, Mapping, Match, Optional, Tuple, Union from unidecode import unidecode # Renames