From 3f93889bc3e490d6b6b34d7c8091ed501aa7f3e3 Mon Sep 17 00:00:00 2001 From: Kai Chen Date: Tue, 11 Dec 2018 23:42:22 -0800 Subject: [PATCH] Add option to validate templates using jinja2schema Use jinja2schema to validate templates when --validate-templates is passed in. Closes https://github.com/thanethomson/statik/issues/99 --- requirements.txt | 1 + statik/cmdline.py | 9 ++++++++- statik/generator.py | 4 ++-- statik/project.py | 4 +++- statik/utils.py | 29 +++++++++++++++++++++++++++++ statik/views.py | 15 +++++++++++---- tests/modular/test_utils.py | 10 +++++++++- 7 files changed, 63 insertions(+), 9 deletions(-) diff --git a/requirements.txt b/requirements.txt index 6779e7f..175fa47 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,3 +21,4 @@ Unidecode==1.0.22 watchdog==0.9.0 paramiko==2.4.2 git+https://github.com/kx-chen/netlify_deployer.git#egg=netlify_uploader +jinja2schema diff --git a/statik/cmdline.py b/statik/cmdline.py index fc5174e..839cc9a 100644 --- a/statik/cmdline.py +++ b/statik/cmdline.py @@ -160,6 +160,12 @@ def main(): help='Display version info for Statik', action='store_true', ) + + group_info.add_argument( + '--validate-templates', + help='Validate that variables used in jinja templates are actually available to be used.', + action='store_true', + ) args = parser.parse_args() error_context = StatikErrorContext() @@ -215,7 +221,8 @@ def main(): output_path=output_path, in_memory=False, safe_mode=args.safe_mode, - error_context=error_context + error_context=error_context, + validate_templates=args.validate_templates, ) if args.upload and args.upload == 'SFTP': diff --git a/statik/generator.py b/statik/generator.py index ad4fc8e..28edd5e 100644 --- a/statik/generator.py +++ b/statik/generator.py @@ -8,8 +8,8 @@ ] -def generate(input_path, output_path=None, in_memory=False, safe_mode=False, error_context=None): +def generate(input_path, output_path=None, in_memory=False, safe_mode=False, error_context=None, validate_templates=False): """Executes the Statik site generator using the given parameters. """ - project = StatikProject(input_path, safe_mode=safe_mode, error_context=error_context) + project = StatikProject(input_path, validate_templates=validate_templates, safe_mode=safe_mode, error_context=error_context) return project.generate(output_path=output_path, in_memory=in_memory) diff --git a/statik/project.py b/statik/project.py index 0f1497a..42b145b 100644 --- a/statik/project.py +++ b/statik/project.py @@ -47,6 +47,7 @@ def __init__(self, path, **kwargs): """ self.error_context = kwargs.pop('error_context', None) self.error_context = self.error_context or StatikErrorContext() + self.validate_templates = kwargs.pop('validate_templates', False) if 'config' in kwargs and isinstance(kwargs['config'], dict): logger.debug("Loading project configuration from constructor arguments") @@ -251,7 +252,8 @@ def process_views(self): view.process( self.db, safe_mode=self.safe_mode, - extra_context=self.project_context + extra_context=self.project_context, + validate_templates=self.validate_templates, ) ) except StatikError as exc: diff --git a/statik/utils.py b/statik/utils.py index 2eb1755..eff638d 100644 --- a/statik/utils.py +++ b/statik/utils.py @@ -12,6 +12,7 @@ import re import six +from jinja2schema import to_json_schema, infer if six.PY3: import importlib.util @@ -46,6 +47,7 @@ 'uncapitalize', 'find_duplicates_in_array', 'camel_to_snake', + 'validate_jinja_template', ] DEFAULT_CONFIG_CONTENT = """project-name: Your project name @@ -385,5 +387,32 @@ def find_duplicates_in_array(array): return duplicates + def camel_to_snake(camel): return '_'.join(re.findall(r'[A-Z][a-z]*', camel)) + + +def validate_jinja_template(template, ctx): + """Checks that variables used in templates are actually available to be used in context. + Logs a warning if a variable used in the template is not found in the context. + + Args: + template: Path to the template to check + ctx: dictionary of the variables in context + """ + tag_re = re.compile(r'(|<[^>]*>)') + with open(template) as file: + stripped_template = tag_re.sub('', file.read()) + try: + result = to_json_schema(infer(stripped_template)) + except Exception as e: + logger.warning( + "Template inspection using jinja2schema failed in %s: %s", + template, e) + return + + for item in result['required']: + if item not in list(ctx.keys()): + logger.warning("'%s' was used in a template, " + "but it's not available to be used. Template: %s" + % (item, template)) diff --git a/statik/views.py b/statik/views.py index cfcafe0..4223936 100644 --- a/statik/views.py +++ b/statik/views.py @@ -10,6 +10,7 @@ from statik.errors import * from statik.utils import * from statik.context import StatikContext +from statik.utils import validate_jinja_template import logging @@ -248,9 +249,12 @@ def __repr__(self): def __str__(self): return repr(self) - def render(self, context, db=None, safe_mode=False, extra_context=None): + def render(self, context, db=None, safe_mode=False, extra_context=None, validate_templates=False): ctx = context.build(db=db, safe_mode=safe_mode, extra=extra_context) logger.debug("Rendering view %s with context: %s", self.view_name, ctx) + if validate_templates: + validate_jinja_template(self.template.filename, ctx) + return dict_from_path( self.path.render(), final_value=self.template.render(ctx) @@ -279,7 +283,7 @@ def __repr__(self): def __str__(self): return repr(self) - def render(self, context, db=None, safe_mode=False, extra_context=None): + def render(self, context, db=None, safe_mode=False, extra_context=None, validate_templates=False): """Renders the given context using the specified database, returning a dictionary containing path segments and rendered view contents.""" if not db: @@ -302,6 +306,8 @@ def render(self, context, db=None, safe_mode=False, extra_context=None): extra=extra_ctx ) inst_path = self.path.render(inst=inst, context=ctx) + if validate_templates: + validate_jinja_template(self.template.filename, ctx) rendered_view = self.template.render(ctx) rendered_views = deep_merge_dict( rendered_views, @@ -387,13 +393,14 @@ def __repr__(self): def __str__(self): return repr(self) - def process(self, db, safe_mode=False, extra_context=None): + def process(self, db, safe_mode=False, extra_context=None, validate_templates=False): """Deprecated. Rather use StatikView.render().""" return self.renderer.render( self.context, db, safe_mode=safe_mode, - extra_context=extra_context + extra_context=extra_context, + validate_templates=validate_templates, ) def render(self, db, safe_mode=False, extra_context=None): diff --git a/tests/modular/test_utils.py b/tests/modular/test_utils.py index 9b9f88e..08daafb 100644 --- a/tests/modular/test_utils.py +++ b/tests/modular/test_utils.py @@ -7,7 +7,6 @@ from statik.utils import * - TEST_XML = """
Hello world! @@ -85,6 +84,15 @@ def test_get_url_file_ext(self): '' ) + def test_validate_jinja_template(self): + from mock import patch, mock_open + with patch("statik.utils.logger.warning") as mock_logger: + with patch("statik.utils.open", mock_open(read_data="{{ baz }}")): + validate_jinja_template('/path/to/somewhere', {'posts': 'foo'}) + mock_logger.assert_called_with("'baz' was used in a template, " + "but it's not available to be used. " + "Template: /path/to/somewhere") + if __name__ == "__main__": unittest.main()