diff --git a/betty/extension/nginx/serve.py b/betty/extension/nginx/serve.py index 8db598e52..9fc42d945 100644 --- a/betty/extension/nginx/serve.py +++ b/betty/extension/nginx/serve.py @@ -87,7 +87,7 @@ def public_url(self) -> str: @classmethod def is_available(cls) -> bool: try: - docker.from_env().info() + docker.from_env() return True except DockerException as e: logging.getLogger(__name__).warning(e) diff --git a/betty/tests/extension/nginx/test___init__.py b/betty/tests/extension/nginx/test___init__.py index b92cae74c..c3d496767 100644 --- a/betty/tests/extension/nginx/test___init__.py +++ b/betty/tests/extension/nginx/test___init__.py @@ -1,379 +1,23 @@ -import re -from typing import Optional - from betty.app import App from betty.extension import Nginx -from betty.extension.nginx.config import NginxConfiguration from betty.generate import generate -from betty.project import ExtensionConfiguration, LocaleConfiguration +from betty.project import ExtensionConfiguration class TestNginx: - _LEADING_WHITESPACE_PATTERN = re.compile(r"^\s*(.*?)$") - - def _normalize_configuration(self, configuration: str) -> str: - return "\n".join( - filter( - None, - map(self._normalize_configuration_line, configuration.splitlines()), - ) - ) - - def _normalize_configuration_line(self, line: str) -> Optional[str]: - match = self._LEADING_WHITESPACE_PATTERN.fullmatch(line) - if match is None: - return None - return match.group(1) - - async def _assert_configuration_equals(self, expected: str, app: App): - await generate(app) - with open( - app.project.configuration.output_directory_path / "nginx" / "nginx.conf" - ) as f: - actual = f.read() - assert self._normalize_configuration(expected) == self._normalize_configuration( - actual - ) - - async def test_post_render_config(self): + async def test_generate(self): async with App.new_temporary() as app, app: app.project.configuration.base_url = "http://example.com" app.project.configuration.extensions.append(ExtensionConfiguration(Nginx)) - expected = ( - r""" -server { - add_header Vary Accept-Language; - add_header Cache-Control "max-age=86400"; - listen 80; - server_name example.com; - root %s; - gzip on; - gzip_disable "msie6"; - gzip_vary on; - gzip_types text/css application/javascript application/json application/xml; - - set $media_type_extension html; - index index.$media_type_extension; - - location / { - # Handle HTTP error responses. - error_page 401 /.error/401.$media_type_extension; - error_page 403 /.error/403.$media_type_extension; - error_page 404 /.error/404.$media_type_extension; - location /.error { - internal; - } - - try_files $uri $uri/ =404; - } -} -""" - % app.project.configuration.www_directory_path - ) - await self._assert_configuration_equals(expected, app) - - async def test_post_render_config_multilingual(self): - async with App.new_temporary() as app, app: - app.project.configuration.base_url = "http://example.com" - app.project.configuration.locales.replace( - LocaleConfiguration( - "en-US", - alias="en", - ), - LocaleConfiguration( - "nl-NL", - alias="nl", - ), - ) - app.project.configuration.extensions.append(ExtensionConfiguration(Nginx)) - expected = ( - r""" -server { - add_header Vary Accept-Language; - add_header Cache-Control "max-age=86400"; - listen 80; - server_name example.com; - root %s; - gzip on; - gzip_disable "msie6"; - gzip_vary on; - gzip_types text/css application/javascript application/json application/xml; - - set $media_type_extension html; - index index.$media_type_extension; - - location @localized_redirect { - set $locale_alias en; - return 301 /$locale_alias$uri; - } - - - # The front page. - location = / { - # nginx does not support redirecting to named locations, so we use try_files with an empty first - # argument and assume that never matches a real file. - try_files '' @localized_redirect; - } - - # Localized resources. - location ~* ^/(en|nl)(/|$) { - set $locale $1; - add_header Vary Accept-Language; - add_header Cache-Control "max-age=86400"; - add_header Content-Language "$locale" always; - - # Handle HTTP error responses. - error_page 401 /$locale/.error/401.$media_type_extension; - error_page 403 /$locale/.error/403.$media_type_extension; - error_page 404 /$locale/.error/404.$media_type_extension; - location ~ ^/$locale/\.error { - internal; - } - - try_files $uri $uri/ =404; - } - - # Static resources. - location / { - # Handle HTTP error responses. - error_page 401 /en/.error/401.$media_type_extension; - error_page 403 /en/.error/403.$media_type_extension; - error_page 404 /en/.error/404.$media_type_extension; - location ~ ^/en/\.error { - internal; - } - try_files $uri $uri/ =404; - } -} -""" - % app.project.configuration.www_directory_path - ) - await self._assert_configuration_equals(expected, app) - - async def test_post_render_config_multilingual_with_clean_urls(self): - async with App.new_temporary() as app, app: - app.project.configuration.base_url = "http://example.com" - app.project.configuration.clean_urls = True - app.project.configuration.locales.replace( - LocaleConfiguration( - "en-US", - alias="en", - ), - LocaleConfiguration( - "nl-NL", - alias="nl", - ), - ) - app.project.configuration.extensions.append(ExtensionConfiguration(Nginx)) - expected = ( - r""" -server { - add_header Vary Accept-Language; - add_header Cache-Control "max-age=86400"; - listen 80; - server_name example.com; - root %s; - gzip on; - gzip_disable "msie6"; - gzip_vary on; - gzip_types text/css application/javascript application/json application/xml; - - set_by_lua_block $media_type_extension { - local available_media_types = {'text/html', 'application/json'} - local media_type_extensions = {} - media_type_extensions['text/html'] = 'html' - media_type_extensions['application/json'] = 'json' - local media_type = require('content_negotiation').negotiate(ngx.req.get_headers()['Accept'], available_media_types) - return media_type_extensions[media_type] - } - index index.$media_type_extension; - location @localized_redirect { - set_by_lua_block $locale_alias { - local available_locales = {'en-US', 'nl-NL'} - local locale_aliases = {} - locale_aliases['en-US'] = 'en' - locale_aliases['nl-NL'] = 'nl' - local locale = require('content_negotiation').negotiate(ngx.req.get_headers()['Accept-Language'], available_locales) - return locale_aliases[locale] - } - add_header Vary Accept-Language; - add_header Cache-Control "max-age=86400"; - add_header Content-Language "$locale_alias" always; - - return 307 /$locale_alias$uri; - } - - # The front page. - location = / { - # nginx does not support redirecting to named locations, so we use try_files with an empty first - # argument and assume that never matches a real file. - try_files '' @localized_redirect; - } - - # Localized resources. - location ~* ^/(en|nl)(/|$) { - set $locale $1; - add_header Vary Accept-Language; - add_header Cache-Control "max-age=86400"; - add_header Content-Language "$locale" always; - - # Handle HTTP error responses. - error_page 401 /$locale/.error/401.$media_type_extension; - error_page 403 /$locale/.error/403.$media_type_extension; - error_page 404 /$locale/.error/404.$media_type_extension; - location ~ ^/$locale/\.error { - internal; - } - - try_files $uri $uri/ =404; - } - - # Static resources. - location / { - # Handle HTTP error responses. - error_page 401 /en/.error/401.$media_type_extension; - error_page 403 /en/.error/403.$media_type_extension; - error_page 404 /en/.error/404.$media_type_extension; - location ~ ^/en/\.error { - internal; - } - try_files $uri $uri/ =404; - } -} -""" - % app.project.configuration.www_directory_path - ) - await self._assert_configuration_equals(expected, app) - - async def test_post_render_config_with_clean_urls(self): - async with App.new_temporary() as app, app: - app.project.configuration.base_url = "http://example.com" - app.project.configuration.clean_urls = True - app.project.configuration.extensions.append(ExtensionConfiguration(Nginx)) - expected = ( - r""" -server { - add_header Vary Accept-Language; - add_header Cache-Control "max-age=86400"; - listen 80; - server_name example.com; - root %s; - gzip on; - gzip_disable "msie6"; - gzip_vary on; - gzip_types text/css application/javascript application/json application/xml; - - set_by_lua_block $media_type_extension { - local available_media_types = {'text/html', 'application/json'} - local media_type_extensions = {} - media_type_extensions['text/html'] = 'html' - media_type_extensions['application/json'] = 'json' - local media_type = require('content_negotiation').negotiate(ngx.req.get_headers()['Accept'], available_media_types) - return media_type_extensions[media_type] - } - index index.$media_type_extension; - - location / { - # Handle HTTP error responses. - error_page 401 /.error/401.$media_type_extension; - error_page 403 /.error/403.$media_type_extension; - error_page 404 /.error/404.$media_type_extension; - location /.error { - internal; - } - - try_files $uri $uri/ =404; - } -}""" - % app.project.configuration.www_directory_path - ) - await self._assert_configuration_equals(expected, app) - - async def test_post_render_config_with_https(self): - async with App.new_temporary() as app, app: - app.project.configuration.base_url = "https://example.com" - app.project.configuration.extensions.append(ExtensionConfiguration(Nginx)) - expected = ( - r""" -server { - listen 80; - server_name example.com; - return 301 https://$host$request_uri; -} -server { - add_header Vary Accept-Language; - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - add_header Cache-Control "max-age=86400"; - listen 443 ssl http2; - server_name example.com; - root %s; - gzip on; - gzip_disable "msie6"; - gzip_vary on; - gzip_types text/css application/javascript application/json application/xml; - - set $media_type_extension html; - index index.$media_type_extension; - - location / { - # Handle HTTP error responses. - error_page 401 /.error/401.$media_type_extension; - error_page 403 /.error/403.$media_type_extension; - error_page 404 /.error/404.$media_type_extension; - location /.error { - internal; - } - - try_files $uri $uri/ =404; - } -} -""" - % app.project.configuration.www_directory_path - ) - await self._assert_configuration_equals(expected, app) - - async def test_post_render_config_with_overridden_www_directory_path(self): - async with App.new_temporary() as app, app: - app.project.configuration.extensions.append( - ExtensionConfiguration( - Nginx, - extension_configuration=NginxConfiguration( - www_directory_path="/tmp/overridden-www", - ), - ) - ) - expected = """ -server { - listen 80; - server_name example.com; - return 301 https://$host$request_uri; -} -server { - add_header Vary Accept-Language; - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - add_header Cache-Control "max-age=86400"; - listen 443 ssl http2; - server_name example.com; - root /tmp/overridden-www; - gzip on; - gzip_disable "msie6"; - gzip_vary on; - gzip_types text/css application/javascript application/json application/xml; - - set $media_type_extension html; - index index.$media_type_extension; - - location / { - # Handle HTTP error responses. - error_page 401 /.error/401.$media_type_extension; - error_page 403 /.error/403.$media_type_extension; - error_page 404 /.error/404.$media_type_extension; - location /.error { - internal; - } - - try_files $uri $uri/ =404; - } -} -""" - await self._assert_configuration_equals(expected, app) + await generate(app) + assert ( + app.project.configuration.output_directory_path / "nginx" / "nginx.conf" + ).exists() + assert ( + app.project.configuration.output_directory_path + / "nginx" + / "content_negotiation.lua" + ).exists() + assert ( + app.project.configuration.output_directory_path / "nginx" / "Dockerfile" + ).exists() diff --git a/betty/tests/extension/nginx/test_artifact.py b/betty/tests/extension/nginx/test_artifact.py new file mode 100644 index 000000000..5289b18d0 --- /dev/null +++ b/betty/tests/extension/nginx/test_artifact.py @@ -0,0 +1,404 @@ +import re +from typing import Optional + +from betty.app import App +from betty.extension import Nginx +from betty.extension.nginx.artifact import ( + generate_configuration_file, + generate_dockerfile_file, +) +from betty.extension.nginx.config import NginxConfiguration +from betty.project import ExtensionConfiguration, LocaleConfiguration + + +class TestGenerateConfigurationFile: + _LEADING_WHITESPACE_PATTERN = re.compile(r"^\s*(.*?)$") + + def _normalize_configuration(self, configuration: str) -> str: + return "\n".join( + filter( + None, + map(self._normalize_configuration_line, configuration.splitlines()), + ) + ) + + def _normalize_configuration_line(self, line: str) -> Optional[str]: + match = self._LEADING_WHITESPACE_PATTERN.fullmatch(line) + if match is None: + return None + return match.group(1) + + async def _assert_configuration_equals(self, expected: str, app: App): + await generate_configuration_file(app) + with open( + app.project.configuration.output_directory_path / "nginx" / "nginx.conf" + ) as f: + actual = f.read() + assert self._normalize_configuration(expected) == self._normalize_configuration( + actual + ) + + async def test(self): + async with App.new_temporary() as app, app: + app.project.configuration.base_url = "http://example.com" + app.project.configuration.extensions.append(ExtensionConfiguration(Nginx)) + expected = ( + r""" +server { + add_header Vary Accept-Language; + add_header Cache-Control "max-age=86400"; + listen 80; + server_name example.com; + root %s; + gzip on; + gzip_disable "msie6"; + gzip_vary on; + gzip_types text/css application/javascript application/json application/xml; + + set $media_type_extension html; + index index.$media_type_extension; + + location / { + # Handle HTTP error responses. + error_page 401 /.error/401.$media_type_extension; + error_page 403 /.error/403.$media_type_extension; + error_page 404 /.error/404.$media_type_extension; + location /.error { + internal; + } + + try_files $uri $uri/ =404; + } +} +""" + % app.project.configuration.www_directory_path + ) + await self._assert_configuration_equals(expected, app) + + async def test_multilingual(self): + async with App.new_temporary() as app, app: + app.project.configuration.base_url = "http://example.com" + app.project.configuration.locales.replace( + LocaleConfiguration( + "en-US", + alias="en", + ), + LocaleConfiguration( + "nl-NL", + alias="nl", + ), + ) + app.project.configuration.extensions.append(ExtensionConfiguration(Nginx)) + expected = ( + r""" +server { + add_header Vary Accept-Language; + add_header Cache-Control "max-age=86400"; + listen 80; + server_name example.com; + root %s; + gzip on; + gzip_disable "msie6"; + gzip_vary on; + gzip_types text/css application/javascript application/json application/xml; + + set $media_type_extension html; + index index.$media_type_extension; + + location @localized_redirect { + set $locale_alias en; + return 301 /$locale_alias$uri; + } + + + # The front page. + location = / { + # nginx does not support redirecting to named locations, so we use try_files with an empty first + # argument and assume that never matches a real file. + try_files '' @localized_redirect; + } + + # Localized resources. + location ~* ^/(en|nl)(/|$) { + set $locale $1; + add_header Vary Accept-Language; + add_header Cache-Control "max-age=86400"; + add_header Content-Language "$locale" always; + + # Handle HTTP error responses. + error_page 401 /$locale/.error/401.$media_type_extension; + error_page 403 /$locale/.error/403.$media_type_extension; + error_page 404 /$locale/.error/404.$media_type_extension; + location ~ ^/$locale/\.error { + internal; + } + + try_files $uri $uri/ =404; + } + + # Static resources. + location / { + # Handle HTTP error responses. + error_page 401 /en/.error/401.$media_type_extension; + error_page 403 /en/.error/403.$media_type_extension; + error_page 404 /en/.error/404.$media_type_extension; + location ~ ^/en/\.error { + internal; + } + try_files $uri $uri/ =404; + } +} +""" + % app.project.configuration.www_directory_path + ) + await self._assert_configuration_equals(expected, app) + + async def test_multilingual_with_clean_urls(self): + async with App.new_temporary() as app, app: + app.project.configuration.base_url = "http://example.com" + app.project.configuration.clean_urls = True + app.project.configuration.locales.replace( + LocaleConfiguration( + "en-US", + alias="en", + ), + LocaleConfiguration( + "nl-NL", + alias="nl", + ), + ) + app.project.configuration.extensions.append(ExtensionConfiguration(Nginx)) + expected = ( + r""" +server { + add_header Vary Accept-Language; + add_header Cache-Control "max-age=86400"; + listen 80; + server_name example.com; + root %s; + gzip on; + gzip_disable "msie6"; + gzip_vary on; + gzip_types text/css application/javascript application/json application/xml; + + set_by_lua_block $media_type_extension { + local available_media_types = {'text/html', 'application/json'} + local media_type_extensions = {} + media_type_extensions['text/html'] = 'html' + media_type_extensions['application/json'] = 'json' + local media_type = require('content_negotiation').negotiate(ngx.req.get_headers()['Accept'], available_media_types) + return media_type_extensions[media_type] + } + index index.$media_type_extension; + location @localized_redirect { + set_by_lua_block $locale_alias { + local available_locales = {'en-US', 'nl-NL'} + local locale_aliases = {} + locale_aliases['en-US'] = 'en' + locale_aliases['nl-NL'] = 'nl' + local locale = require('content_negotiation').negotiate(ngx.req.get_headers()['Accept-Language'], available_locales) + return locale_aliases[locale] + } + add_header Vary Accept-Language; + add_header Cache-Control "max-age=86400"; + add_header Content-Language "$locale_alias" always; + + return 307 /$locale_alias$uri; + } + + # The front page. + location = / { + # nginx does not support redirecting to named locations, so we use try_files with an empty first + # argument and assume that never matches a real file. + try_files '' @localized_redirect; + } + + # Localized resources. + location ~* ^/(en|nl)(/|$) { + set $locale $1; + add_header Vary Accept-Language; + add_header Cache-Control "max-age=86400"; + add_header Content-Language "$locale" always; + + # Handle HTTP error responses. + error_page 401 /$locale/.error/401.$media_type_extension; + error_page 403 /$locale/.error/403.$media_type_extension; + error_page 404 /$locale/.error/404.$media_type_extension; + location ~ ^/$locale/\.error { + internal; + } + + try_files $uri $uri/ =404; + } + + # Static resources. + location / { + # Handle HTTP error responses. + error_page 401 /en/.error/401.$media_type_extension; + error_page 403 /en/.error/403.$media_type_extension; + error_page 404 /en/.error/404.$media_type_extension; + location ~ ^/en/\.error { + internal; + } + try_files $uri $uri/ =404; + } +} +""" + % app.project.configuration.www_directory_path + ) + await self._assert_configuration_equals(expected, app) + + async def test_with_clean_urls(self): + async with App.new_temporary() as app, app: + app.project.configuration.base_url = "http://example.com" + app.project.configuration.clean_urls = True + app.project.configuration.extensions.append(ExtensionConfiguration(Nginx)) + expected = ( + r""" +server { + add_header Vary Accept-Language; + add_header Cache-Control "max-age=86400"; + listen 80; + server_name example.com; + root %s; + gzip on; + gzip_disable "msie6"; + gzip_vary on; + gzip_types text/css application/javascript application/json application/xml; + + set_by_lua_block $media_type_extension { + local available_media_types = {'text/html', 'application/json'} + local media_type_extensions = {} + media_type_extensions['text/html'] = 'html' + media_type_extensions['application/json'] = 'json' + local media_type = require('content_negotiation').negotiate(ngx.req.get_headers()['Accept'], available_media_types) + return media_type_extensions[media_type] + } + index index.$media_type_extension; + + location / { + # Handle HTTP error responses. + error_page 401 /.error/401.$media_type_extension; + error_page 403 /.error/403.$media_type_extension; + error_page 404 /.error/404.$media_type_extension; + location /.error { + internal; + } + + try_files $uri $uri/ =404; + } +}""" + % app.project.configuration.www_directory_path + ) + await self._assert_configuration_equals(expected, app) + + async def test_with_https(self): + async with App.new_temporary() as app, app: + app.project.configuration.base_url = "https://example.com" + app.project.configuration.extensions.append(ExtensionConfiguration(Nginx)) + expected = ( + r""" +server { + listen 80; + server_name example.com; + return 301 https://$host$request_uri; +} +server { + add_header Vary Accept-Language; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Cache-Control "max-age=86400"; + listen 443 ssl http2; + server_name example.com; + root %s; + gzip on; + gzip_disable "msie6"; + gzip_vary on; + gzip_types text/css application/javascript application/json application/xml; + + set $media_type_extension html; + index index.$media_type_extension; + + location / { + # Handle HTTP error responses. + error_page 401 /.error/401.$media_type_extension; + error_page 403 /.error/403.$media_type_extension; + error_page 404 /.error/404.$media_type_extension; + location /.error { + internal; + } + + try_files $uri $uri/ =404; + } +} +""" + % app.project.configuration.www_directory_path + ) + await self._assert_configuration_equals(expected, app) + + async def test_with_overridden_www_directory_path(self): + async with App.new_temporary() as app, app: + app.project.configuration.extensions.append( + ExtensionConfiguration( + Nginx, + extension_configuration=NginxConfiguration( + www_directory_path="/tmp/overridden-www", + ), + ) + ) + expected = """ +server { + listen 80; + server_name example.com; + return 301 https://$host$request_uri; +} +server { + add_header Vary Accept-Language; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Cache-Control "max-age=86400"; + listen 443 ssl http2; + server_name example.com; + root /tmp/overridden-www; + gzip on; + gzip_disable "msie6"; + gzip_vary on; + gzip_types text/css application/javascript application/json application/xml; + + set $media_type_extension html; + index index.$media_type_extension; + + location / { + # Handle HTTP error responses. + error_page 401 /.error/401.$media_type_extension; + error_page 403 /.error/403.$media_type_extension; + error_page 404 /.error/404.$media_type_extension; + location /.error { + internal; + } + + try_files $uri $uri/ =404; + } +} +""" + await self._assert_configuration_equals(expected, app) + + +class TestGenerateDockerfileFile: + async def test(self) -> None: + async with App.new_temporary() as app, app: + app.project.configuration.extensions.append( + ExtensionConfiguration( + Nginx, + extension_configuration=NginxConfiguration( + www_directory_path="/tmp/overridden-www", + ), + ) + ) + await generate_dockerfile_file(app) + assert ( + app.project.configuration.output_directory_path + / "nginx" + / "content_negotiation.lua" + ).exists() + assert ( + app.project.configuration.output_directory_path / "nginx" / "Dockerfile" + ).exists() diff --git a/betty/tests/extension/nginx/test_serve.py b/betty/tests/extension/nginx/test_serve.py index de363b4e6..798cda190 100644 --- a/betty/tests/extension/nginx/test_serve.py +++ b/betty/tests/extension/nginx/test_serve.py @@ -4,6 +4,8 @@ import pytest import requests from aiofiles.os import makedirs +from docker.errors import DockerException +from pytest_mock import MockerFixture from requests import Response from betty.app import App @@ -12,14 +14,15 @@ from betty.extension.nginx.serve import DockerizedNginxServer from betty.functools import Do from betty.project import ExtensionConfiguration +from betty.serve import NoPublicUrlBecauseServerNotStartedError -@pytest.mark.skipif( - sys.platform in {"darwin", "win32"}, - reason="macOS and Windows do not natively support Docker.", -) class TestDockerizedNginxServer: - async def test(self): + @pytest.mark.skipif( + sys.platform in {"darwin", "win32"}, + reason="macOS and Windows do not natively support Docker.", + ) + async def test_context_manager(self): content = "Hello, and welcome to my site!" async with App.new_temporary() as app, app: app.project.configuration.extensions.append( @@ -43,3 +46,30 @@ def _assert_response(response: Response) -> None: assert "no-cache" == response.headers["Cache-Control"] await Do(requests.get, server.public_url).until(_assert_response) + + async def test_public_url_unstarted(self) -> None: + async with App.new_temporary() as app, app: + app.project.configuration.extensions.enable(Nginx) + sut = DockerizedNginxServer(app) + with pytest.raises(NoPublicUrlBecauseServerNotStartedError): + sut.public_url + + async def test_is_available_is_available(self, mocker: MockerFixture) -> None: + async with App.new_temporary() as app, app: + app.project.configuration.extensions.enable(Nginx) + sut = DockerizedNginxServer(app) + + m_from_env = mocker.patch("docker.from_env") + m_from_env.return_value = mocker.Mock("docker.client.DockerClient") + + assert sut.is_available() + + async def test_is_available_is_unavailable(self, mocker: MockerFixture) -> None: + async with App.new_temporary() as app, app: + app.project.configuration.extensions.enable(Nginx) + sut = DockerizedNginxServer(app) + + m_from_env = mocker.patch("docker.from_env") + m_from_env.side_effect = DockerException() + + assert not sut.is_available() diff --git a/betty/tests/test_tests_exist.py b/betty/tests/test_tests_exist.py index 89883c060..960627608 100644 --- a/betty/tests/test_tests_exist.py +++ b/betty/tests/test_tests_exist.py @@ -26,7 +26,6 @@ class TestTestsExist: "betty.dispatch", "betty.error", "betty.extension.__init__", - "betty.extension.nginx.artifact", "betty.extension.nginx.docker", "betty.extension.webpack.jinja2.__init__", "betty.extension.webpack.jinja2.filter",