From 02cf0e95e5f1bf09b82bc741b763950c018dda70 Mon Sep 17 00:00:00 2001 From: Luke Carrier Date: Sat, 13 Apr 2024 01:27:46 +0800 Subject: [PATCH] Introduce a Configuration type We need to make an effort to type the BasePlugin.config dict, as we're otherwise having to unpack and pass many arguments to some of the more complicated callbacks. I'm trying to straddle the line between a reasonable number of args and overly tight coupling by only using this object in methods which actually do processing for now, and not those involved in computing configuration. --- mkdocs_drawio_exporter/exporter.py | 43 ++++++++---- mkdocs_drawio_exporter/plugin.py | 8 +-- mkdocs_drawio_exporter/tests/exporter.py | 86 ++++++++++++++++++------ 3 files changed, 100 insertions(+), 37 deletions(-) diff --git a/mkdocs_drawio_exporter/exporter.py b/mkdocs_drawio_exporter/exporter.py index 951f009..beea431 100644 --- a/mkdocs_drawio_exporter/exporter.py +++ b/mkdocs_drawio_exporter/exporter.py @@ -5,11 +5,29 @@ import shutil import subprocess import sys +from typing import TypedDict IMAGE_RE = re.compile('(]+src=")([^">]+)("\\s*\\/?>)') +class Configuration(TypedDict): + """Draw.io Exporter MkDocs plugin configuration. + + Contains the resolved configuration values, including defaults and any + computed values (such as paths to binaries). + + Seems ugly to shadow BasePlugin.config_scheme, but improves type hints and + allows us to more easily pass configuration around.""" + + cache_dir: str + drawio_executable: str + drawio_args: list[str] + format: str + embed_format: str + sources: str + + class ConfigurationError(Exception): """Configuration exception. @@ -203,7 +221,7 @@ def prepare_drawio_executable(self, executable, executable_names, platform_execu raise ConfigurationError.drawio_executable( None, 'Unable to find Draw.io executable; ensure it\'s on PATH or set drawio_executable option') - def rewrite_image_embeds(self, output_content, sources, format, embed_format): + def rewrite_image_embeds(self, output_content, config: Configuration): """Rewrite image embeds. :param str output_content: Content to rewrite. @@ -221,11 +239,11 @@ def replace(match): filename = match.group(2) page_index = 0 - if fnmatch.fnmatch(filename, sources): + if fnmatch.fnmatch(filename, config["sources"]): content_sources.append(Source(filename, page_index)) - img_src = f"{filename}-{page_index}.{format}" + img_src = f"{filename}-{page_index}.{config["format"]}" - return embed_format.format( + return config["embed_format"].format( img_open=match.group(1), img_close=match.group(3), img_src=img_src) else: @@ -243,7 +261,7 @@ def filter_cache_files(self, files, cache_dir): """ return [f for f in files if not f.abs_src_path.startswith(cache_dir)] - def ensure_file_cached(self, source, source_rel, page_index, drawio_executable, drawio_args, cache_dir, format): + def ensure_file_cached(self, source, source_rel, page_index, config: Configuration): """Ensure cached copy of output exists. :param str source: Source path, absolute. @@ -255,20 +273,19 @@ def ensure_file_cached(self, source, source_rel, page_index, drawio_executable, :param str format: Desired export format. :return tuple(str, int): Cached export filename. """ - cache_filename = self.make_cache_filename(source_rel, page_index, cache_dir) + cache_filename = self.make_cache_filename(source_rel, page_index, config['cache_dir']) exit_status = None if self.use_cached_file(source, cache_filename): self.log.debug(f'Source file appears unchanged; using cached copy from "{cache_filename}"') else: - if not drawio_executable: + if not config['drawio_executable']: self.log.warning(f'Skipping export of "{source}" as Draw.io executable not available') return (None, exit_status) self.log.debug(f'Exporting "{source}" to "{cache_filename}"') exit_status = self.export_file( - source, page_index, cache_filename, - drawio_executable, drawio_args, format) + source, page_index, cache_filename, config) return (cache_filename, exit_status) @@ -294,7 +311,7 @@ def use_cached_file(self, source, cache_filename): return os.path.exists(cache_filename) \ and os.path.getmtime(cache_filename) >= os.path.getmtime(source) - def export_file(self, source, page_index, dest, drawio_executable, drawio_args, format): + def export_file(self, source, page_index, dest, config: Configuration): """Export an individual file. :param str source: Source path, absolute. @@ -306,13 +323,13 @@ def export_file(self, source, page_index, dest, drawio_executable, drawio_args, :return int: The Draw.io exit status. """ cmd = [ - drawio_executable, + config['drawio_executable'], '--export', source, '--page-index', str(page_index), '--output', dest, - '--format', format, + '--format', config['format'], ] - cmd += drawio_args + cmd += config['drawio_args'] try: self.log.debug(f'Using export command {cmd}') diff --git a/mkdocs_drawio_exporter/plugin.py b/mkdocs_drawio_exporter/plugin.py index 2ce9ffe..647abc7 100644 --- a/mkdocs_drawio_exporter/plugin.py +++ b/mkdocs_drawio_exporter/plugin.py @@ -7,7 +7,7 @@ from mkdocs.structure.files import Files from mkdocs.utils import copy_file -from .exporter import ConfigurationError, DrawIoExporter, Source +from .exporter import ConfigurationError, DrawIoExporter, Configuration log = mkdocs.plugins.log.getChild('drawio-exporter') @@ -54,8 +54,7 @@ def on_config(self, config): def on_post_page(self, output_content, page, **kwargs): output_content, content_sources = self.exporter.rewrite_image_embeds( - output_content, self.config['sources'], - self.config['format'], self.config['embed_format']) + output_content, self.config) for source in content_sources: source.resolve_rel_path(page.file.dest_path) @@ -80,8 +79,7 @@ def on_post_build(self, config): abs_dest_path = os.path.join(config['site_dir'], dest_rel_path) cache_filename, exit_status = self.exporter.ensure_file_cached( abs_src_path, source.source_rel, source.page_index, - self.config['drawio_executable'], self.config['drawio_args'], - self.config['cache_dir'], self.config['format']) + self.config) if exit_status not in (None, 0): log.error(f'Export failed with exit status {exit_status}; skipping copy') diff --git a/mkdocs_drawio_exporter/tests/exporter.py b/mkdocs_drawio_exporter/tests/exporter.py index 534f734..1e321aa 100644 --- a/mkdocs_drawio_exporter/tests/exporter.py +++ b/mkdocs_drawio_exporter/tests/exporter.py @@ -6,7 +6,7 @@ from os.path import isabs, join, sep import re -from ..exporter import ConfigurationError, DrawIoExporter +from ..exporter import Configuration, ConfigurationError, DrawIoExporter class FileMock: @@ -26,6 +26,20 @@ def setUp(self): self.log = logging.getLogger(__name__) self.exporter = DrawIoExporter(self.log) + def make_config(self, **kwargs): + defaults = { + 'cache_dir': 'drawio-exporter', + 'drawio_executable': 'drawio', + 'drawio_args': [], + 'format': 'svg', + 'embed_format': '{img_open}{img_src}{img_close}', + 'sources': '*.drawio', + } + # FIXME: when dropping support for Python 3.8, replace with the merge + # operator (|). + values = {**defaults, **kwargs} + return Configuration(**values) + def test_drawio_executable_paths_warns_on_unknown_platform(self): self.log.warning = MagicMock() self.exporter.drawio_executable_paths('win32-but-stable') @@ -77,23 +91,24 @@ def test_prepare_drawio_executable_raises_on_failure(self): def test_rewrite_image_embeds(self): source = '''

Example text

Some text''' - - default_embed_format = '{img_open}{img_src}{img_close}' object_embed_format = '' + config = self.make_config(sources='*.nomatch') output_content, sources = self.exporter.rewrite_image_embeds( - source, '*.nomatch', 'svg', default_embed_format) + source, config) assert output_content == source assert sources == [] + config = self.make_config() output_content, sources = self.exporter.rewrite_image_embeds( - source, '*.drawio', 'svg', default_embed_format) + source, config) assert output_content != source assert 'src="../some-diagram.drawio-0.svg"' in output_content assert len(sources) == 1 + config = self.make_config(embed_format=object_embed_format) output_content, sources = self.exporter.rewrite_image_embeds( - source, '*.drawio', 'svg', object_embed_format) + source, config) assert output_content != source assert '' in output_content assert len(sources) == 1 @@ -118,6 +133,11 @@ def test_ensure_file_cached(self): drawio_executable = sep + join('bin', 'drawio') cache_dir = sep + join('docs', 'drawio-exporter') + config = self.make_config( + cache_dir=cache_dir, + drawio_executable=drawio_executable, + ) + self.exporter.make_cache_filename = MagicMock() self.exporter.make_cache_filename.return_value = join(cache_dir, '0000000000000000000000000000000000000000-0') @@ -125,7 +145,7 @@ def test_ensure_file_cached(self): self.exporter.export_file.return_value = 0 cache_filename, exit_status = self.exporter.ensure_file_cached( - source, source_rel, 0, drawio_executable, [], cache_dir, 'svg') + source, source_rel, 0, config) assert cache_filename == self.exporter.make_cache_filename.return_value assert exit_status == 0 @@ -135,13 +155,18 @@ def test_ensure_file_cached_aborts_if_drawio_executable_unavailable(self): drawio_executable = None cache_dir = sep + join('docs', 'drawio-exporter') + config = self.make_config( + cache_dir=cache_dir, + drawio_executable=drawio_executable, + ) + self.exporter.export_file = MagicMock() self.exporter.export_file.return_value = 0 self.log.warning = MagicMock() cache_filename, exit_status = self.exporter.ensure_file_cached( - source, source_rel, 0, drawio_executable, [], cache_dir, 'svg') + source, source_rel, 0, config) assert exit_status == None self.log.warning.assert_called_once() @@ -152,6 +177,11 @@ def test_ensure_file_cached_skips_export_if_cache_fresh(self): drawio_executable = sep + join('bin', 'drawio') cache_dir = sep + join('docs', 'drawio-exporter') + config = self.make_config( + cache_dir=cache_dir, + drawio_executable=drawio_executable, + ) + self.exporter.make_cache_filename = MagicMock() self.exporter.make_cache_filename.return_value = join(cache_dir, '0000000000000000000000000000000000000000-0') @@ -162,7 +192,7 @@ def test_ensure_file_cached_skips_export_if_cache_fresh(self): self.exporter.export_file.return_value = 0 cache_filename, exit_status = self.exporter.ensure_file_cached( - source, source_rel, 0, drawio_executable, [], cache_dir, 'svg') + source, source_rel, 0, config) assert cache_filename == self.exporter.make_cache_filename.return_value assert exit_status == None @@ -175,6 +205,11 @@ def test_ensure_file_cached_returns_exit_status_if_non_zero(self): drawio_executable = sep + join('bin', 'drawio') cache_dir = sep + join('docs', 'drawio-exporter') + config = self.make_config( + cache_dir=cache_dir, + drawio_executable=drawio_executable, + ) + self.exporter.make_cache_filename = MagicMock() self.exporter.make_cache_filename.return_value = join(cache_dir, '0000000000000000000000000000000000000000-0') @@ -187,7 +222,7 @@ def test_ensure_file_cached_returns_exit_status_if_non_zero(self): self.log.error = MagicMock() cache_filename, exit_status = self.exporter.ensure_file_cached( - source, source_rel, 0, drawio_executable, [], cache_dir, 'svg') + source, source_rel, 0, config) assert exit_status == 1 @@ -230,10 +265,14 @@ def test_export_file(self, call_mock): dest = sep + join('docs', 'diagram.drawio-0.svg') drawio_executable = sep + join('bin', 'drawio') + config = self.make_config( + drawio_executable=drawio_executable, + ) + call_mock.return_value = 0 result = self.exporter.export_file( - source, 0, dest, drawio_executable, [], 'svg') + source, 0, dest, config) assert result == 0 call_mock.assert_called_once() @@ -244,12 +283,16 @@ def test_export_file_logs_exc_on_raise(self, call_mock): dest = sep + join('docs', 'diagram.drawio-0.svg') drawio_executable = sep + join('bin', 'drawio') + config = self.make_config( + drawio_executable=drawio_executable, + ) + call_mock.side_effect = OSError() self.log.exception = MagicMock() result = self.exporter.export_file( - source, 0, dest, drawio_executable, [], 'svg') + source, 0, dest, config) assert result == None self.log.exception.assert_called_once() @@ -261,21 +304,26 @@ def test_export_file_honours_drawio_args(self, call_mock): page_index = 0 dest = sep + join('docs', 'diagram.drawio-0.svg') drawio_executable = sep + join('bin', 'drawio') - format = 'svg' - def test_drawio_args(drawio_args): + config = self.make_config( + drawio_executable=drawio_executable, + format='svg' + ) + + def test_drawio_args(config: Configuration, drawio_args): + test_config = {**config, 'drawio_args': drawio_args} self.exporter.export_file( - source, page_index, dest, drawio_executable, drawio_args, format) + source, page_index, dest, test_config) call_mock.assert_called_with([ - drawio_executable, + config['drawio_executable'], '--export', source, '--page-index', str(page_index), '--output', dest, - '--format', format, + '--format', config['format'], ] + drawio_args) - test_drawio_args([]) - test_drawio_args(['--no-sandbox']) + test_drawio_args(config, []) + test_drawio_args(config, ['--no-sandbox']) if __name__ == '__main__':