diff --git a/src/octoprint/settings.py b/src/octoprint/settings.py index 122e4413..a7137e14 100644 --- a/src/octoprint/settings.py +++ b/src/octoprint/settings.py @@ -30,7 +30,7 @@ import logging import re import uuid -from octoprint.util import atomic_write +from octoprint.util import atomic_write, is_hidden_path _APPNAME = "OctoPrint" @@ -405,10 +405,12 @@ class Settings(object): return folder def _init_script_templating(self): - from jinja2 import Environment, BaseLoader, FileSystemLoader, ChoiceLoader, TemplateNotFound - from jinja2.nodes import Include, Const + from jinja2 import Environment, BaseLoader, ChoiceLoader, TemplateNotFound + from jinja2.nodes import Include from jinja2.ext import Extension + from octoprint.util.jinja import FilteredFileSystemLoader + class SnippetExtension(Extension): tags = {"snippet"} fields = Include.fields @@ -497,10 +499,14 @@ class Settings(object): else: return template - file_system_loader = FileSystemLoader(self.getBaseFolder("scripts")) + path_filter = lambda path: not is_hidden_path(path) + file_system_loader = FilteredFileSystemLoader(self.getBaseFolder("scripts"), + path_filter=path_filter) settings_loader = SettingsScriptLoader(self) choice_loader = ChoiceLoader([file_system_loader, settings_loader]) - select_loader = SelectLoader(choice_loader, dict(bundled=settings_loader, file=file_system_loader)) + select_loader = SelectLoader(choice_loader, + dict(bundled=settings_loader, + file=file_system_loader)) return RelEnvironment(loader=select_loader, extensions=[SnippetExtension]) def _get_script_template(self, script_type, name, source=False): diff --git a/src/octoprint/util/__init__.py b/src/octoprint/util/__init__.py index b665167e..fa8d42ff 100644 --- a/src/octoprint/util/__init__.py +++ b/src/octoprint/util/__init__.py @@ -614,6 +614,23 @@ def bom_aware_open(filename, encoding="ascii", mode="r", **kwargs): return codecs.open(filename, encoding=encoding, mode=mode, **kwargs) +def is_hidden_path(path): + filename = os.path.basename(path) + if filename.startswith("."): + return True + + if sys.platform == "win32": + try: + import ctypes + attrs = ctypes.windll.kernel32.GetFileAttributesW(unicode(path)) + assert attrs != -1 + return bool(attrs & 2) + except (AttributeError, AssertionError): + pass + + return False + + class RepeatedTimer(threading.Thread): """ This class represents an action that should be run repeatedly in an interval. It is similar to python's diff --git a/src/octoprint/util/jinja.py b/src/octoprint/util/jinja.py new file mode 100644 index 00000000..bf3f8b30 --- /dev/null +++ b/src/octoprint/util/jinja.py @@ -0,0 +1,50 @@ +# coding=utf-8 +from __future__ import absolute_import + +__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__copyright__ = "Copyright (C) 2015 The OctoPrint Project - Released under terms of the AGPLv3 License" + +import os + +from jinja2.loaders import FileSystemLoader, TemplateNotFound, split_template_path + +class FilteredFileSystemLoader(FileSystemLoader): + """ + Jinja2 ``FileSystemLoader`` subclass that allows filtering templates. + + Only such templates will be accessible for whose paths the provided + ``path_filter`` filter function returns True. + + ``path_filter`` will receive the actual path on disc and should behave just + like callables provided to Python's internal ``filter`` function, returning + ``True`` if the path is cleared and ``False`` if it is supposed to be removed + from results and hence ``filter(path_filter, iterable)`` should be + equivalent to ``[item for item in iterable if path_filter(item)]``. + + If ``path_filter`` is not set or not a ``callable``, the loader will + behave just like the regular Jinja2 ``FileSystemLoader``. + """ + def __init__(self, searchpath, path_filter=None, **kwargs): + FileSystemLoader.__init__(self, searchpath, **kwargs) + self.path_filter = path_filter + + def get_source(self, environment, template): + if callable(self.path_filter): + pieces = split_template_path(template) + if not self._combined_filter(os.path.join(*pieces)): + raise TemplateNotFound(template) + + return FileSystemLoader.get_source(self, environment, template) + + def list_templates(self): + result = FileSystemLoader.list_templates(self) + + if callable(self.path_filter): + result = sorted(filter(self._combined_filter, result)) + + return result + + def _combined_filter(self, path): + filter_results = map(lambda x: not os.path.exists(os.path.join(x, path)) or self.path_filter(os.path.join(x, path)), + self.searchpath) + return all(filter_results) diff --git a/tests/util/_files/jinja_test_data/.hidden_everywhere.txt b/tests/util/_files/jinja_test_data/.hidden_everywhere.txt new file mode 100644 index 00000000..2277db7a --- /dev/null +++ b/tests/util/_files/jinja_test_data/.hidden_everywhere.txt @@ -0,0 +1 @@ +hidden_everywhere diff --git a/tests/util/_files/jinja_test_data/normal_text.txt b/tests/util/_files/jinja_test_data/normal_text.txt new file mode 100644 index 00000000..5685cd07 --- /dev/null +++ b/tests/util/_files/jinja_test_data/normal_text.txt @@ -0,0 +1 @@ +normal_text diff --git a/tests/util/_files/jinja_test_data/not_a_text.dat b/tests/util/_files/jinja_test_data/not_a_text.dat new file mode 100644 index 00000000..7376e561 --- /dev/null +++ b/tests/util/_files/jinja_test_data/not_a_text.dat @@ -0,0 +1 @@ +not_a_text diff --git a/tests/util/test_file_helpers.py b/tests/util/test_file_helpers.py index 8f298606..bc82fa60 100644 --- a/tests/util/test_file_helpers.py +++ b/tests/util/test_file_helpers.py @@ -164,3 +164,40 @@ class TestAtomicWrite(unittest.TestCase): mock_tempfile.assert_called_once_with(mode="w", prefix="foo", suffix="bar", delete=False) mock_file.close.assert_called_once_with() mock_move.assert_called_once_with("tempfile.tmp", "somefile.yaml") + +class IsHiddenPathTest(unittest.TestCase): + + def setUp(self): + import tempfile + + self.basepath = tempfile.mkdtemp() + + self.path_always_visible = os.path.join(self.basepath, "always_visible.txt") + self.path_hidden_on_windows = os.path.join(self.basepath, "hidden_on_windows.txt") + self.path_always_hidden = os.path.join(self.basepath, ".always_hidden.txt") + + for attr in ("path_always_visible", "path_hidden_on_windows", "path_always_hidden"): + path = getattr(self, attr) + with open(path, "w+b") as f: + f.write(attr) + + import sys + if sys.platform == "win32": + # we use ctypes and the windows API to set the hidden attribute on the file + # only hidden on windows + import ctypes + ctypes.windll.kernel32.SetFileAttributesW(unicode(self.path_hidden_on_windows), 2) + + def tearDown(self): + import shutil + shutil.rmtree(self.basepath) + + def test_is_hidden_path(self): + self.assertFalse(octoprint.util.is_hidden_path(self.path_always_visible)) + self.assertTrue(octoprint.util.is_hidden_path(self.path_always_hidden)) + + import sys + if sys.platform == "win32": + self.assertTrue(octoprint.util.is_hidden_path(self.path_hidden_on_windows)) + else: + self.assertFalse(octoprint.util.is_hidden_path(self.path_hidden_on_windows)) diff --git a/tests/util/test_jinja.py b/tests/util/test_jinja.py new file mode 100644 index 00000000..f391cdee --- /dev/null +++ b/tests/util/test_jinja.py @@ -0,0 +1,76 @@ +# coding=utf-8 +from __future__ import absolute_import + +__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__copyright__ = "Copyright (C) 2015 The OctoPrint Project - Released under terms of the AGPLv3 License" + + +import unittest +import os +import jinja2 + +from ddt import ddt, data, unpack + +import octoprint.util.jinja + +NONE_FILTER = None +HIDDEN_FILTER = lambda x: not os.path.basename(x).startswith(".") +NO_TXT_FILTER = lambda x: x.endswith(".txt") +COMBINED_FILTER = lambda x: HIDDEN_FILTER(x) and NO_TXT_FILTER(x) + +@ddt +class FilteredFileSystemLoaderTest(unittest.TestCase): + + def setUp(self): + self.basepath = os.path.join(os.path.abspath(os.path.dirname(__file__)), "_files", "jinja_test_data") + self.environment = jinja2.Environment() + + def loader_factory(self, path_filter): + return octoprint.util.jinja.FilteredFileSystemLoader(self.basepath, + path_filter=path_filter) + + @data( + (NONE_FILTER, [".hidden_everywhere.txt", "normal_text.txt", "not_a_text.dat"]), + (HIDDEN_FILTER, ["normal_text.txt", "not_a_text.dat"]), + (NO_TXT_FILTER, [".hidden_everywhere.txt", "normal_text.txt"]), + (COMBINED_FILTER, ["normal_text.txt"]) + ) + @unpack + def test_list_templates(self, path_filter, expected): + loader = self.loader_factory(path_filter=path_filter) + templates = loader.list_templates() + self.assertListEqual(templates, expected) + + @data( + (NONE_FILTER, ((".hidden_everywhere.txt", True), + ("normal_text.txt", True), + ("not_a_text.dat", True))), + (HIDDEN_FILTER, ((".hidden_everywhere.txt", False), + ("normal_text.txt", True), + ("not_a_text.dat", True))), + (NO_TXT_FILTER, ((".hidden_everywhere.txt", True), + ("normal_text.txt", True), + ("not_a_text.dat", False))), + (COMBINED_FILTER, ((".hidden_everywhere.txt", False), + ("normal_text.txt", True), + ("not_a_text.dat", False))) + ) + @unpack + def test_get_source_none_filter(self, path_filter, param_sets): + loader = self.loader_factory(path_filter=path_filter) + for param_set in param_sets: + template, success = param_set + if success: + self._test_get_source_success(loader, template) + else: + self._test_get_source_notfound(loader, template) + + def _test_get_source_success(self, loader, template): + loader.get_source(self.environment, template) + + def _test_get_source_notfound(self, loader, template): + try: + loader.get_source(self.environment, template) + self.fail("Expected an exception") + except jinja2.TemplateNotFound: + pass