diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58d63783..6cc75645 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,22 +32,25 @@ jobs: - name: Install test dependencies run: | pip install pybtex + - name: Setup Java + uses: actions/setup-java@v1 + with: + java-version: 8 - name: Install PlantUML env: - PLANTUML_VERSION: 1.2020.24 - run: | - # Not using "apt-get install plantuml" because it's an old version - sudo apt-get install default-jre-headless - - # Avoid Java logging "Created user preferences directory" - mkdir -p ~/.java/.userPrefs - - wget -q -O "${{github.workspace}}/plantuml.jar" "https://repo1.maven.org/maven2/net/sourceforge/plantuml/plantuml/${PLANTUML_VERSION}/plantuml-${PLANTUML_VERSION}.jar" + PLANTUML_VERSION: 1.2021.4 + run: wget -q -O "${{github.workspace}}/plantuml.jar" "https://repo1.maven.org/maven2/net/sourceforge/plantuml/plantuml/${PLANTUML_VERSION}/plantuml-${PLANTUML_VERSION}.jar" - name: Run tests env: PLANTUML_EXEC: java -Djava.awt.headless=true -jar ${{github.workspace}}/plantuml.jar run: | py.test --color=yes tests/ + - name: Run PlantUML PicoWeb tests + env: + PLANTUML_SYSTEM: picoweb + PLANTUML_PICOWEB_START_COMMAND: java -Djava.awt.headless=true -jar ${{github.workspace}}/plantuml.jar -picoweb:0:localhost + run: | + py.test --color=yes tests/test_plantuml* flake8: name: Linting (flake8) strategy: diff --git a/tests/__init__.py b/tests/__init__.py index 68a35aea..5c8f5499 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,4 +1,4 @@ -import re +import os from functools import lru_cache from pathlib import Path from textwrap import dedent @@ -11,6 +11,7 @@ __all__ = [ 'cached_property', 'execute_plugin_tasks', + 'getenv_split', 'simple_html_page', 'TEST_DATA_PATH', 'V7_PLUGIN_PATH', @@ -36,6 +37,10 @@ def execute_plugin_tasks(plugin: Task, verbosity: int = 0): raise Exception("Task error for '{}'\n{}".format(t.name, catched.get_msg())) +def getenv_split(key: str, default=None): + return os.environ[key].split() if key in os.environ else default + + def simple_html_page(body: str) -> str: return dedent(''' diff --git a/tests/conftest.py b/tests/conftest.py index 6108b21a..a06d4a39 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,7 +12,8 @@ from nikola import Nikola from nikola.post import Post from nikola.utils import LocaleBorg -from tests import cached_property, simple_html_page +from tests import cached_property, getenv_split, simple_html_page +from v8.plantuml.plantuml import PICOWEB_URL_ENV_VAR @fixture @@ -115,6 +116,24 @@ def localeborg_setup(default_locale): LocaleBorg.reset() +@fixture +def maybe_plantuml_picoweb_server(monkeypatch, tmp_site_path): + if os.getenv('PLANTUML_SYSTEM') == 'picoweb': + from v8.plantuml.plantuml import DEFAULT_PLANTUML_PICOWEB_START_COMMAND, DEFAULT_PLANTUML_PICOWEB_START_TIMEOUT_SECONDS, \ + DEFAULT_PLANTUML_PICOWEB_URL, PicoWebSupervisor + supervisor = PicoWebSupervisor() + supervisor.start( + command=getenv_split('PLANTUML_PICOWEB_START_COMMAND', DEFAULT_PLANTUML_PICOWEB_START_COMMAND), + timeout=os.getenv('PLANTUML_PICOWEB_START_TIMEOUT_SECONDS', DEFAULT_PLANTUML_PICOWEB_START_TIMEOUT_SECONDS), + url_template=os.getenv('PLANTUML_PICOWEB_URL', DEFAULT_PLANTUML_PICOWEB_URL), + ) + monkeypatch.setenv(PICOWEB_URL_ENV_VAR, supervisor.url) + yield + supervisor.stop() + else: + yield + + @fixture def tmp_site_path(monkeypatch, tmp_path): monkeypatch.chdir(tmp_path) diff --git a/tests/test_plantuml.py b/tests/test_plantuml.py index 43f1555e..3677ba9c 100644 --- a/tests/test_plantuml.py +++ b/tests/test_plantuml.py @@ -2,12 +2,12 @@ from textwrap import dedent from typing import Dict -from tests import execute_plugin_tasks -from v8.plantuml.plantuml import PlantUmlTask +from tests import execute_plugin_tasks, getenv_split +from v8.plantuml.plantuml import DEFAULT_PLANTUML_EXEC, DEFAULT_PLANTUML_SYSTEM, PlantUmlTask # Note this test is also sufficient to prove that rendering binary image files will work -def test_render_file_success(tmp_site_path): +def test_render_file_success(maybe_plantuml_picoweb_server, tmp_site_path): (tmp_site_path / 'pages' / 'test.puml').write_text(dedent('''\ @startuml title filename="%filename()" @@ -21,7 +21,7 @@ def test_render_file_success(tmp_site_path): (tmp_site_path / 'pages' / 'includes' / 'include2.iuml').write_text('participant "included-2"') plugin = create_plugin({ - 'PLANTUML_ARGS': [ + 'PLANTUML_FILE_OPTIONS': [ '-chide footbox', '-Ipages/includes/include1.iuml', ], @@ -47,7 +47,7 @@ def test_render_file_success(tmp_site_path): ] -def test_render_file_error(tmp_site_path): +def test_render_file_error(maybe_plantuml_picoweb_server, tmp_site_path): (tmp_site_path / 'pages' / 'test.puml').write_text(dedent('''\ @startuml A -> B @@ -104,7 +104,7 @@ def test_gen_tasks(tmp_site_path): def test_task_depends_on_included_files(tmp_site_path): plugin = create_plugin({ - 'PLANTUML_ARGS': [ + 'PLANTUML_FILE_OPTIONS': [ '-Iincludes/include1.iuml', '-Iincludes/include2.iuml', '-Iincludes/bar*.iuml', @@ -142,10 +142,10 @@ def __init__(self, config: Dict): 'FILTERS': {}, 'OUTPUT_FOLDER': 'output', 'PLANTUML_DEBUG': True, + 'PLANTUML_EXEC': getenv_split('PLANTUML_EXEC', DEFAULT_PLANTUML_EXEC), + 'PLANTUML_SYSTEM': os.getenv('PLANTUML_SYSTEM', DEFAULT_PLANTUML_SYSTEM), } self.config.update(config) - if 'PLANTUML_EXEC' in os.environ: - self.config['PLANTUML_EXEC'] = os.environ['PLANTUML_EXEC'].split() def plugin_tasks(plugin): diff --git a/tests/test_plantuml_markdown.py b/tests/test_plantuml_markdown.py index 387c980e..66574251 100644 --- a/tests/test_plantuml_markdown.py +++ b/tests/test_plantuml_markdown.py @@ -7,8 +7,9 @@ if sys.version_info < (3, 6): raise pytest.skip("plantuml_markdown plugin requires Python >= 3.6", allow_module_level=True) -from tests import V8_PLUGIN_PATH +from tests import V8_PLUGIN_PATH, getenv_split from tests.conftest import CompileResult +from v8.plantuml.plantuml import DEFAULT_PLANTUML_EXEC, DEFAULT_PLANTUML_SYSTEM from v8.plantuml_markdown.plantuml_markdown import PlantUmlMarkdownProcessor, first_line_for_listing_block @@ -146,7 +147,7 @@ def test_first_line_for_listing_block(line, expected): @fixture -def do_compile_test(basic_compile_test): +def do_compile_test(basic_compile_test, maybe_plantuml_picoweb_server): def f(data: str, plantuml_continue_after_failure=False) -> CompileResult: return basic_compile_test( '.md', @@ -154,12 +155,13 @@ def f(data: str, plantuml_continue_after_failure=False) -> CompileResult: extra_config={ 'PLANTUML_DEBUG': True, 'PLANTUML_CONTINUE_AFTER_FAILURE': plantuml_continue_after_failure, - 'PLANTUML_EXEC': os.environ.get('PLANTUML_EXEC', 'plantuml').split(), - 'PLANTUML_MARKDOWN_ARGS': [ + 'PLANTUML_EXEC': getenv_split('PLANTUML_EXEC', DEFAULT_PLANTUML_EXEC), + 'PLANTUML_MARKDOWN_OPTIONS': [ '-chide footbox', '-nometadata', '-Sshadowing=false', ], + 'PLANTUML_SYSTEM': os.getenv('PLANTUML_SYSTEM', DEFAULT_PLANTUML_SYSTEM), }, extra_plugins_dirs=[ V8_PLUGIN_PATH / 'plantuml', diff --git a/v8/plantuml/CHANGES.md b/v8/plantuml/CHANGES.md new file mode 100644 index 00000000..55ac9176 --- /dev/null +++ b/v8/plantuml/CHANGES.md @@ -0,0 +1,12 @@ +# 1.0.0 +* Add support for PlantUML PicoWeb server. +* Rename `PLANTUML_ARGS` config option to `PLANTUML_FILE_OPTIONS`. + +# 0.2.0 +* Add `PlantUmlTask.plantuml_manager` so the `plantuml_markdown` plugin can use it. + +# 0.1.1 +* Update `PLANTUML_FILES` in `conf.py.sample` to match the default behaviour. + +# 0.1 +* First release. diff --git a/v8/plantuml/README.md b/v8/plantuml/README.md index 6aebd6d5..d2a71f06 100644 --- a/v8/plantuml/README.md +++ b/v8/plantuml/README.md @@ -2,22 +2,17 @@ This plugin converts [PlantUML](https://plantuml.com/) files. The default configuration will output all `*.puml` files found under the `plantuml` dir as SVG files. -Developed against PlantUML version 1.2020.24. Probably works with some earlier versions. - # Unicode The plugin expects PlantUML files to be encoded with UTF-8. # Known Issues -- It's slow! Every PlantUML rendering launches a new Java process, on my laptop it takes 4-8 seconds per file. - I have some ideas to speed this up, and they may be available in future plugin versions. - - Changes to files included via `!include ...` or via a pattern (e.g. `-Ipath/to/*.iuml`) will NOT trigger a rebuild. - Instead, if you include them explicitly in `PLANTUML_ARGS` (e.g. `-Ipath/to/foo.iuml`) then they will trigger a - rebuild. + Instead, if you include them explicitly in `PLANTUML_FILE_OPTIONS` (e.g. `-Ipath/to/foo.iuml`) then they will trigger + a rebuild. -- `nikola auto` does not watch dirs in `PLANTUML_FILES` or files included via `PLANTUML_ARGS` / `!include`. +- `nikola auto` does not watch dirs in `PLANTUML_FILES` or files included via `PLANTUML_FILE_OPTIONS` / `!include`. As a workaround you could put PlantUML files under any dir listed in `POSTS` or `PAGES` because those dirs are watched. (Use `.iuml` suffix for include files to prevent them matching the `*.puml` wildcard in `PLANTUML_FILES`) diff --git a/v8/plantuml/conf.py.sample b/v8/plantuml/conf.py.sample index 141581b8..3b995ec2 100644 --- a/v8/plantuml/conf.py.sample +++ b/v8/plantuml/conf.py.sample @@ -1,7 +1,24 @@ +# +# PLANTUML_SYSTEM ('exec' or 'picoweb') +# +# exec The PLANTUML_EXEC command is run separately for each rendering, +# this starts a new Java process each time so will be slow. +# +# picoweb An HTTP request is sent to PLANTUML_PICOWEB_URL for each rendering, this is much faster than 'exec'. +# +# If PLANTUML_PICOWEB_START_COMMAND is an empty list then Nikola assumes PicoWeb is already running, +# otherwise Nikola will run PLANTUML_PICOWEB_START_COMMAND the first time PicoWeb is needed. +# +# However if the 'PLANTUML_PICOWEB_URL' environment variable is set then that URL is used and +# PLANTUML_PICOWEB_START_COMMAND / PLANTUML_PICOWEB_URL config options are ignored. +# This is kind of a kludge so 'nikola auto' can start the server and share it with child builds processes. +# +PLANTUML_SYSTEM = 'exec' + # # PLANTUML_EXEC (list of strings) - The command to run PlantUML # -# '%site_path%' anywhere in PLANTUML_EXEC will be replaced with the full path to the site dir. +# %site_path% anywhere in PLANTUML_EXEC will be replaced with the full path to the site dir. # PlantUML is run in the site dir so often this is not needed. # # Examples @@ -16,7 +33,7 @@ PLANTUML_EXEC = ['plantuml'] # -# PLANTUML_ARGS (list of strings) - CLI arguments that are sent to PlantUML when rendering PlantUML files, +# PLANTUML_FILE_OPTIONS (list of strings) - options used when rendering PlantUML files, # see https://plantuml.com/command-line # # Examples @@ -27,10 +44,10 @@ PLANTUML_EXEC = ['plantuml'] # Specify the style in conf.py # [ '-chide footbox', '-SShadowing=false' ] # -PLANTUML_ARGS = [] +PLANTUML_FILE_OPTIONS = [] # -# PLANTUML_FILES contains (wildcard, destination, extension, args) tuples. +# PLANTUML_FILES contains (wildcard, destination, extension, options) tuples. # # is used to generate a list of source files in the same way as POSTS and PAGES. # @@ -39,7 +56,7 @@ PLANTUML_ARGS = [] # # As with POSTS and PAGES you can create any directory structure you want and it will be reflected in the output. # -# is a list of cli arguments that are appended to PLANTUML_ARGS +# is a list of strings that is appended to PLANTUML_FILE_OPTIONS # PLANTUML_FILES = ( ('plantuml/*.puml', 'plantuml', '.svg', ['-tsvg']), @@ -48,7 +65,7 @@ PLANTUML_FILES = ( # # PLANTUML_CONTINUE_AFTER_FAILURE (boolean) - If True then Nikola will continue executing after any PlantUML failures. # -# PlantUML puts its error messages in the rendered output so you might find this option helpful when running `nikola auto`. +# PlantUML puts its error messages in the rendered output so you might find this option helpful when running 'nikola auto'. # PLANTUML_CONTINUE_AFTER_FAILURE = False @@ -56,3 +73,29 @@ PLANTUML_CONTINUE_AFTER_FAILURE = False # PLANTUML_DEBUG (boolean) - Control plugin verbosity # PLANTUML_DEBUG = False + +# +# PLANTUML_PICOWEB_START_COMMAND (list of strings) - The command to start a PlantUML PicoWeb Server +# +# %site_path% anywhere in PLANTUML_PICOWEB_START_COMMAND will be replaced with the full path to the site dir. +# PlantUML is run in the site dir so often this is not needed. +# +PLANTUML_PICOWEB_START_COMMAND = ['plantuml', '-picoweb:0:localhost'] + +# +# PLANTUML_PICOWEB_URL (string) - URL of the PicoWeb Server +# +# If Nikola starts a PicoWeb Server then %port% anywhere in PLANTUML_PICOWEB_URL will be replaced by the actual +# port number of the server. +# +PLANTUML_PICOWEB_URL = 'http://localhost:%port%' + +# +# PLANTUML_PICOWEB_START_TIMEOUT_SECONDS (int) - Maximum time to wait for the PicoWeb server to start. +# +PLANTUML_PICOWEB_START_TIMEOUT_SECONDS = 30 + +# +# PLANTUML_PICOWEB_RENDER_TIMEOUT_SECONDS (int) - Maximum time to wait for a single rendering. +# +PLANTUML_PICOWEB_RENDER_TIMEOUT_SECONDS = 30 diff --git a/v8/plantuml/plantuml.plugin b/v8/plantuml/plantuml.plugin index 21a4c7ef..ad03afbb 100644 --- a/v8/plantuml/plantuml.plugin +++ b/v8/plantuml/plantuml.plugin @@ -9,6 +9,6 @@ PluginCategory = Task [Documentation] Author = Matthew Leather -Version = 0.2.0 +Version = 1.0.0 Website = https://plugins.getnikola.com/#plantuml Description = Renders PlantUML files diff --git a/v8/plantuml/plantuml.py b/v8/plantuml/plantuml.py index 8cd89955..0cf3fe64 100644 --- a/v8/plantuml/plantuml.py +++ b/v8/plantuml/plantuml.py @@ -1,15 +1,23 @@ +import json import os import subprocess from itertools import chain -from logging import DEBUG +from logging import DEBUG, INFO, WARNING from pathlib import Path -from typing import List, Optional, Sequence, Tuple +from queue import Empty, Queue +from subprocess import Popen +from threading import Lock, Thread, main_thread +from typing import Iterable, List, Optional, Sequence, Tuple + +import blinker +import requests +from requests import HTTPError from nikola import utils from nikola.log import get_logger from nikola.plugin_categories import Task -DEFAULT_PLANTUML_ARGS = [] +DEFAULT_PLANTUML_FILE_OPTIONS = [] DEFAULT_PLANTUML_DEBUG = False @@ -21,6 +29,18 @@ DEFAULT_PLANTUML_CONTINUE_AFTER_FAILURE = False +DEFAULT_PLANTUML_PICOWEB_RENDER_TIMEOUT_SECONDS = 30 + +DEFAULT_PLANTUML_PICOWEB_START_COMMAND = ['plantuml', '-picoweb:0:localhost'] + +DEFAULT_PLANTUML_PICOWEB_START_TIMEOUT_SECONDS = 30 + +DEFAULT_PLANTUML_PICOWEB_URL = 'http://localhost:%port%' + +DEFAULT_PLANTUML_SYSTEM = 'exec' + +PICOWEB_URL_ENV_VAR = 'PLANTUML_PICOWEB_URL' + # TODO when 3.5 support is dropped # - Use capture_output arg in subprocess.run() @@ -31,13 +51,15 @@ class PlantUmlTask(Task): name = 'plantuml' - _common_args = ... # type: List[str] - plantuml_manager = ... # Optional[PlantUmlManager] + _file_options = ... # type: List[str] + plantuml_manager = ... # type: Optional[PlantUmlManager] def set_site(self, site): super().set_site(site) - self._common_args = list(site.config.get('PLANTUML_ARGS', DEFAULT_PLANTUML_ARGS)) - self.plantuml_manager = PlantUmlManager(site) + if 'PLANTUML_ARGS' in site.config: + raise Exception('PLANTUML_ARGS is no longer supported, please use PLANTUML_FILE_OPTIONS instead') + self._file_options = list(site.config.get('PLANTUML_FILE_OPTIONS', DEFAULT_PLANTUML_FILE_OPTIONS)) + self.plantuml_manager = PlantUmlManager.create(site) def gen_tasks(self): yield self.group_task() @@ -48,17 +70,17 @@ def gen_tasks(self): output_path = Path(output_folder) # Logic derived from nikola.plugins.misc.scan_posts.ScanPosts.scan() - for pattern, destination, extension, args in plantuml_files: - combined_args = self._common_args + args + for pattern, destination, extension, options in plantuml_files: + combined_options = self._file_options + options kw = { - 'combined_args': combined_args, + 'combined_options': combined_options, 'filters': filters, 'output_folder': output_folder, } # TODO figure out exactly what the PlantUML include patterns do and expand them similarly here - includes = list(set(a[2:] for a in combined_args if a.startswith('-I') and '*' not in a and '?' not in a)) + includes = list(set(a[2:] for a in combined_options if a.startswith('-I') and '*' not in a and '?' not in a)) pattern = Path(pattern) root = pattern.parent @@ -71,14 +93,14 @@ def gen_tasks(self): 'name': dst_str, 'file_dep': includes + [str(src)], 'targets': [dst_str], - 'actions': [(self.render_file, [src, dst, combined_args + ['-filename', src.name]])], + 'actions': [(self.render_file, [src, dst, combined_options + ['-filename', src.name]])], 'uptodate': [utils.config_changed(kw, 'plantuml:' + dst_str)], 'clean': True, } yield utils.apply_filters(task, filters) - def render_file(self, src: Path, dst: Path, args: Sequence[str]) -> bool: - output, error = self.plantuml_manager.render(src.read_bytes(), args) + def render_file(self, src: Path, dst: Path, options: Sequence[str]) -> bool: + output, error = self.plantuml_manager.render(src.read_bytes(), options) dst.parent.mkdir(parents=True, exist_ok=True) dst.write_bytes(output) @@ -96,24 +118,40 @@ def render_file(self, src: Path, dst: Path, args: Sequence[str]) -> bool: class PlantUmlManager: """PlantUmlManager is used by 'plantuml' and 'plantuml_markdown' plugins""" + @classmethod + def create(cls, site): + system = site.config.get('PLANTUML_SYSTEM', DEFAULT_PLANTUML_SYSTEM) + if system == 'exec': + return ExecPlantUmlManager(site) + elif system == 'picoweb': + return PicowebPlantUmlManager(site) + else: + raise ValueError('Unknown PLANTUML_SYSTEM "{}"'.format(system)) + def __init__(self, site) -> None: self.continue_after_failure = site.config.get('PLANTUML_CONTINUE_AFTER_FAILURE', DEFAULT_PLANTUML_CONTINUE_AFTER_FAILURE) - self.exec = site.config.get('PLANTUML_EXEC', DEFAULT_PLANTUML_EXEC) - self.logger = get_logger('plantuml_manager') + self._logger = get_logger(self.__class__.__name__) if site.config.get('PLANTUML_DEBUG', DEFAULT_PLANTUML_DEBUG): - self.logger.level = DEBUG + self._logger.level = DEBUG - def render(self, source: bytes, args: Sequence[str]) -> Tuple[bytes, Optional[str]]: + def render(self, source: bytes, options: Sequence[str]) -> Tuple[bytes, Optional[str]]: """Returns (output, error)""" + raise NotImplementedError + + @staticmethod + def _process_options(options: Iterable[str]) -> Sequence[str]: + return [o.replace('%site_path%', os.getcwd()) for o in options] + - def process_arg(arg): - return arg \ - .replace('%site_path%', os.getcwd()) \ - .encode('utf8') +class ExecPlantUmlManager(PlantUmlManager): + def __init__(self, site) -> None: + super().__init__(site) + self._exec = site.config.get('PLANTUML_EXEC', DEFAULT_PLANTUML_EXEC) - command = list(map(process_arg, chain(self.exec, args, ['-pipe', '-stdrpt']))) + def render(self, source: bytes, options: Sequence[str]) -> Tuple[bytes, Optional[str]]: + command = [o.encode('utf8') for o in self._process_options(chain(self._exec, options, ['-pipe', '-stdrpt']))] - self.logger.debug('render() exec: %s\n%s', command, source) + self._logger.debug('render() exec: %s\n%s', command, source) result = subprocess.run(command, input=source, stdout=subprocess.PIPE, stderr=subprocess.PIPE) @@ -126,3 +164,143 @@ def process_arg(arg): details = str(result.stderr) return result.stdout, "PlantUML error (return code {}): {}".format(result.returncode, details) + + +class PicowebPlantUmlManager(PlantUmlManager): + def __init__(self, site) -> None: + super().__init__(site) + self._lock = Lock() + self._render_timeout = site.config.get('PLANTUML_PICOWEB_RENDER_TIMEOUT_SECONDS', DEFAULT_PLANTUML_PICOWEB_RENDER_TIMEOUT_SECONDS) + self._start_command = site.config.get('PLANTUML_PICOWEB_START_COMMAND', DEFAULT_PLANTUML_PICOWEB_START_COMMAND) + self._start_timeout = site.config.get('PLANTUML_PICOWEB_START_TIMEOUT_SECONDS', DEFAULT_PLANTUML_PICOWEB_START_TIMEOUT_SECONDS) + + if PICOWEB_URL_ENV_VAR in os.environ: + self._server_available = True + self._url = os.getenv(PICOWEB_URL_ENV_VAR) + else: + # If there is no start command then we assume a server is already running + self._server_available = self._start_command == [] + self._url = site.config.get('PLANTUML_PICOWEB_URL', DEFAULT_PLANTUML_PICOWEB_URL) + + if self._server_available: + self._logger.debug('Will use an already running PlantUML PicoWeb server at "%s"', self._url) + + blinker.signal('auto_command_starting').connect(self._on_auto_command_starting) + + def render(self, source: bytes, options: Sequence[str]) -> Tuple[bytes, Optional[str]]: + self._maybe_start_picoweb() + + data = json.dumps({ + 'options': self._process_options(options), + 'source': str(source, 'utf8') + }) + + self._logger.debug('render() %s', data) + response = requests.post(self._url + '/render', data=data, timeout=self._render_timeout, allow_redirects=False) + + if response.status_code == 200: + return response.content, self._error_message_from_picoweb_response(response) + + if response.status_code == 302 \ + and response.headers['location'] == '/plantuml/png/oqbDJyrBuGh8ISmh2VNrKGZ8JCuFJqqAJYqgIotY0aefG5G00000': + raise Exception('This version of PlantUML does not support "POST /render", you need a version >= 1.2021.2') + + try: + response.raise_for_status() + except HTTPError as e: + text = response.text + if text: + raise HTTPError("{} - {}".format(e, text), response=e.response) + else: + raise + + raise Exception('Unexpected {} response from PlantUML PicoWeb server'.format(response.status_code)) + + @staticmethod + def _error_message_from_picoweb_response(response): + if 'X-PlantUML-Diagram-Error' in response.headers: + return "PlantUML Error (line={}): {}".format( + response.headers['X-PlantUML-Diagram-Error-Line'], + response.headers['X-PlantUML-Diagram-Error'] + ) + else: + return None + + def _on_auto_command_starting(self, site): # noqa + self._maybe_start_picoweb() + + def _maybe_start_picoweb(self): + with self._lock: + if self._server_available: + return + supervisor = PicoWebSupervisor() + supervisor.stop_after_main_thread() + supervisor.start( + command=self._process_options(self._start_command), + timeout=self._start_timeout, + url_template=self._url, + ) + self._url = os.environ[PICOWEB_URL_ENV_VAR] = supervisor.url + self._server_available = True + + +class PicoWebSupervisor: + def __init__(self): + self._logger = get_logger('plantuml_picoweb') + self._logging_thread = None + self._process = None + self._queue = Queue() + self.url = None + + def start(self, command: Sequence[str], url_template: str, timeout: int): + command_bytes = [c.encode('utf8') for c in command] + self._logger.info('Starting PlantUML Picoweb server, command=%s', command_bytes) + self._process = Popen(command_bytes, stderr=subprocess.PIPE) + + # Not using a daemon thread for the logging because those can be stopped abruptly and the final log lines might be lost + self._logging_thread = Thread(target=self._process_logging, name='plantuml-picoweb-logging') + self._logging_thread.start() + + port = self._wait_for_port(timeout) + self.url = url_template.replace('%port%', str(port)) + self._logger.info('PlantUML PicoWeb server is listening at "%s"', self.url) + + def _process_logging(self): + looking_for_port = True + for line in self._process.stderr: + if looking_for_port and line.startswith(b'webPort='): + self._queue.put(int(line[8:])) + looking_for_port = False + else: + self._logger.error(str(line, 'utf8').rstrip()) + + exit_code = self._process.wait() + self._logger.log( + INFO if exit_code in [0, 130, 143] else WARNING, + 'PlantUML PicoWeb server finished (exit code %d)', exit_code + ) + self._queue.put('finished') + + def _wait_for_port(self, timeout: int) -> int: + try: + item = self._queue.get(timeout=timeout) + if item == 'finished': + raise Exception('PlantUML PicoWeb server died unexpectedly') + return item + except Empty: + raise Exception('Timeout waiting for PlantUML PicoWeb server to start') + + def stop_after_main_thread(self): + """Ensure the logging thread finishes and we dont leave behind an orphan PicoWeb process""" + + def _stop(): + main_thread().join() + self.stop() + + Thread(target=_stop, name='plantuml-picoweb-stop').start() + + def stop(self): + if self._process: + self._process.terminate() + if self._logging_thread: + self._logging_thread.join() diff --git a/v8/plantuml/requirements-nonpy.txt b/v8/plantuml/requirements-nonpy.txt index d8f007ad..2b20143c 100644 --- a/v8/plantuml/requirements-nonpy.txt +++ b/v8/plantuml/requirements-nonpy.txt @@ -1 +1 @@ -PlantUML::https://plantuml.com/ +PlantUML>=1.2021.2::https://plantuml.com/ diff --git a/v8/plantuml_markdown/CHANGES.md b/v8/plantuml_markdown/CHANGES.md new file mode 100644 index 00000000..92f69834 --- /dev/null +++ b/v8/plantuml_markdown/CHANGES.md @@ -0,0 +1,6 @@ +# 1.0.0 +* Rename `PLANTUML_MARKDOWN_ARGS` config option to `PLANTUML_MARKDOWN_OPTIONS`. +* Update tests to suit `plantuml` plugin v1.0.0. + +# 0.1.0 +* First release. diff --git a/v8/plantuml_markdown/README.md b/v8/plantuml_markdown/README.md index 79869d08..739a263e 100644 --- a/v8/plantuml_markdown/README.md +++ b/v8/plantuml_markdown/README.md @@ -1,8 +1,6 @@ This plugin renders [PlantUML](https://plantuml.com/) in Markdown files. -# Requirements - -* Python >= 3.6 (we use `markdown>=3.3.0` which requires it) +Requires Python >= 3.6 because we use `markdown>=3.3.0` which requires it. # Usage diff --git a/v8/plantuml_markdown/conf.py.sample b/v8/plantuml_markdown/conf.py.sample index 884e6fb2..a520d271 100644 --- a/v8/plantuml_markdown/conf.py.sample +++ b/v8/plantuml_markdown/conf.py.sample @@ -8,11 +8,11 @@ # # -# PLANTUML_MARKDOWN_ARGS (list of strings) - CLI arguments that are sent to PlantUML when rendering for markdown files, +# PLANTUML_MARKDOWN_OPTIONS (list of strings) - options used when rendering for markdown files, # see https://plantuml.com/command-line # -# Note this is independent of PLANTUML_ARGS in the "plantuml" plugin. -# If you want them to be the same then do "PLANTUML_ARGS = PLANTUML_MARKDOWN_ARGS = [ ... ]" +# Note this is independent of PLANTUML_FILE_OPTIONS in the "plantuml" plugin. +# If you want them to be the same then do "PLANTUML_FILE_OPTIONS = PLANTUML_MARKDOWN_OPTIONS = [ ... ]" # # Examples # -------- @@ -22,4 +22,4 @@ # Specify the style in conf.py # [ '-chide footbox', '-SShadowing=false' ] # -PLANTUML_MARKDOWN_ARGS = [] +PLANTUML_MARKDOWN_OPTIONS = [] diff --git a/v8/plantuml_markdown/plantuml_markdown.plugin b/v8/plantuml_markdown/plantuml_markdown.plugin index 145f8255..3e38c6f8 100644 --- a/v8/plantuml_markdown/plantuml_markdown.plugin +++ b/v8/plantuml_markdown/plantuml_markdown.plugin @@ -10,6 +10,6 @@ Compiler = markdown [Documentation] Author = Matthew Leather -Version = 0.1.0 +Version = 1.0.0 Website = https://plugins.getnikola.com/#plantuml_markdown Description = Markdown extension for PlantUML diff --git a/v8/plantuml_markdown/plantuml_markdown.py b/v8/plantuml_markdown/plantuml_markdown.py index 3a27d69a..7aec7d0e 100644 --- a/v8/plantuml_markdown/plantuml_markdown.py +++ b/v8/plantuml_markdown/plantuml_markdown.py @@ -9,15 +9,17 @@ from nikola.plugin_categories import MarkdownExtension from nikola.utils import LocaleBorg, req_missing, slugify -DEFAULT_PLANTUML_MARKDOWN_ARGS = [] +DEFAULT_PLANTUML_MARKDOWN_OPTIONS = [] class PlantUmlMarkdownProcessor(FencedBlockPreprocessor): def __init__(self, md, config, site, logger): super().__init__(md, config) + if 'PLANTUML_MARKDOWN_ARGS' in site.config: + raise Exception('PLANTUML_MARKDOWN_ARGS is no longer supported, please use PLANTUML_MARKDOWN_OPTIONS instead') self._logger = logger self._plantuml_manager = None # Lazily retrieved because it might not exist right now - self._plantuml_markdown_args = list(site.config.get('PLANTUML_MARKDOWN_ARGS', DEFAULT_PLANTUML_MARKDOWN_ARGS)) + self._markdown_options = list(site.config.get('PLANTUML_MARKDOWN_OPTIONS', DEFAULT_PLANTUML_MARKDOWN_OPTIONS)) self._prefix: List[str] = [] self._site: Nikola = site @@ -65,7 +67,7 @@ def listing(): def svg(): rendered_bytes, error = self.plantuml_manager.render( match.group('code').encode('utf8'), - self._plantuml_markdown_args + self._prefix + ['-tsvg'] + self._markdown_options + self._prefix + ['-tsvg'] ) if error: # Note we never "continue" when rendered_bytes is empty because that likely means PlantUML failed to start diff --git a/v8/plantuml_markdown/requirements-nonpy.txt b/v8/plantuml_markdown/requirements-nonpy.txt new file mode 100644 index 00000000..2f0e54e1 --- /dev/null +++ b/v8/plantuml_markdown/requirements-nonpy.txt @@ -0,0 +1 @@ +Python>=3.6::https://www.python.org/