Repository: conan-io/conan
Pull request: Use Jinja2 to parse layout files (editable packages)
Changelog: Feature: Apply Jinja2 to layout files before parsing them
Docs: https://github.com/conan-io/docs/pull/1093

This PR executes Jinja2 over layout files (context contains `reference`, `settings` and `options`) before parsing those files so the user will be able to apply some logic on the constructed path to match the underlying filesystem. As this logic depends on `settings` and `options` we are no longer able to parse these files before knowing the node/settings/options it is going to be applied to.

closes #4424 

Running on _more_ python versions as we are introducing a new dependency:
@PYVERS: Macos@py27, Windows@py36, Linux@py27, py34

Problem statement:
Use jinja2 templates for editable layouts
Syntax in editable layouts are currently using placeholders like ``{settings.build_type}`` to make some conditional logic regarding folder paths.

We knew it has some limitations specially with placeholder like ``{options.shared}``, that will replace the value with a ``True``/``False`` instead of a useful value.

We evaluated the following:
- Inline evaluation of python code
- Evaluation of field headers like ``[includedirs#settings.build_type==True]``
- Python syntax in *layout.py* implementing a ``package_info()``
- Jinja2 templating

And decided that jinja2 is a fairly common orthogonal language used in a lot of tools, syntax is well documented and contemplates many conditional scenarios. Also this should be quite easy to implement.

The information available in this jinja2 templates should be ``options`` and ``settings``.

Hints from issue discussion:
Changed complexity, I think using jinja template engine for this should be relatively easy.

Commit message: remove duplicated test

Patch to review:
diff --git a/conans/client/tools/oss.py b/conans/client/tools/oss.py
index 7521320b979..e61f7cc2457 100644
--- a/conans/client/tools/oss.py
+++ b/conans/client/tools/oss.py
@@ -306,7 +306,7 @@ def detect_windows_subsystem():
                 # https://github.com/Microsoft/WSL/issues/423#issuecomment-221627364
                 with open("/proc/sys/kernel/osrelease") as f:
                     return WSL if f.read().endswith("Microsoft") else None
-            except FileNotFoundError:
+            except IOError:
                 return None
         try:
             output = OSInfo.uname()
diff --git a/conans/model/editable_cpp_info.py b/conans/model/editable_cpp_info.py
index b2ab609199d..3164c43f5ef 100644
--- a/conans/model/editable_cpp_info.py
+++ b/conans/model/editable_cpp_info.py
@@ -2,10 +2,13 @@
 import os
 from collections import OrderedDict
 
+from six import StringIO
 from six.moves import configparser
 
 from conans.errors import ConanException
 from conans.model.ref import ConanFileReference
+from conans.util.files import load
+from conans.util.templates import render_layout_file
 
 DEFAULT_LAYOUT_FILE = "default"
 LAYOUTS_FOLDER = 'layouts'
@@ -14,19 +17,17 @@
 def get_editable_abs_path(path, cwd, cache_folder):
     # Check the layout file exists, is correct, and get its abs-path
     if path:
-        layout_abs_path = path if os.path.isabs(path) else os.path.normpath(os.path.join(cwd, path))
+        layout_abs_path = os.path.normpath(os.path.join(cwd, path))
         if not os.path.isfile(layout_abs_path):
             layout_abs_path = os.path.join(cache_folder, LAYOUTS_FOLDER, path)
         if not os.path.isfile(layout_abs_path):
             raise ConanException("Couldn't find layout file: %s" % path)
-        EditableLayout.load(layout_abs_path)  # Try if it loads ok
         return layout_abs_path
 
     # Default only in cache
-    layout_abs_path = os.path.join(cache_folder, LAYOUTS_FOLDER, DEFAULT_LAYOUT_FILE)
-    if os.path.isfile(layout_abs_path):
-        EditableLayout.load(layout_abs_path)
-        return layout_abs_path
+    layout_default_path = os.path.join(cache_folder, LAYOUTS_FOLDER, DEFAULT_LAYOUT_FILE)
+    if os.path.isfile(layout_default_path):
+        return layout_default_path
 
 
 class EditableLayout(object):
@@ -35,68 +36,76 @@ class EditableLayout(object):
     cpp_info_dirs = ['includedirs', 'libdirs', 'resdirs', 'bindirs', 'builddirs', 'srcdirs']
     folders = [BUILD_FOLDER, SOURCE_FOLDER]
 
-    def __init__(self, data, folders):
-        self._data = data
-        self._folders = folders
+    def __init__(self, filepath):
+        self._filepath = filepath
 
     def folder(self, ref, name, settings, options):
+        _, folders = self._load_data(ref, settings=settings, options=options)
         try:
-            path = self._folders.get(str(ref)) or self._folders.get(None) or {}
-            path = path[name]
+            path = folders.get(str(ref)) or folders.get(None) or {}
+            return path[name]
         except KeyError:
             return None
-        try:
-            return self._work_on_item(path, settings, options)
-        except Exception as e:
-            raise ConanException("Error getting fHolder '%s' from layout: %s" % (str(name), str(e)))
 
     @staticmethod
-    def load(filepath):
-        parser = configparser.ConfigParser(allow_no_value=True)
-        parser.optionxform = str
+    def _work_on_item(value):
+        value = value.replace('\\', '/')
+        return value
+
+    def _parse_layout_file(self, ref, settings, options):
+        content = load(self._filepath)
         try:
-            parser.read(filepath)
-        except configparser.Error as e:
-            raise ConanException("Error parsing layout file: %s\n%s" % (filepath, str(e)))
+            content = render_layout_file(content, ref=ref, settings=settings, options=options)
+
+            parser = configparser.ConfigParser(allow_no_value=True)
+            parser.optionxform = str
+            parser.readfp(StringIO(content))
+        except (configparser.Error, ConanException) as e:
+            raise ConanException("Error parsing layout file '%s' (for reference '%s')\n%s" %
+                                 (self._filepath, str(ref), str(e)))
+
+        return parser
+
+    def _load_data(self, ref, settings, options):
+        parser = self._parse_layout_file(ref, settings, options)
+
+        # Build a convenient data structure
         data = OrderedDict()
         folders = {}
         for section in parser.sections():
-            ref, section_name = section.rsplit(":", 1) if ':' in section else (None, section)
+            reference, section_name = section.rsplit(":", 1) if ':' in section else (None, section)
+
             if section_name in EditableLayout.folders:
                 items = [k for k, _ in parser.items(section)] or [""]
                 if len(items) > 1:
                     raise ConanException("'%s' with more than one value in layout file: %s"
-                                         % (section_name, filepath))
-                folders.setdefault(ref, {})[section_name] = items[0]
+                                         % (section_name, self._filepath))
+                folders.setdefault(reference, {})[section_name] = self._work_on_item(items[0])
                 continue
+
             if section_name not in EditableLayout.cpp_info_dirs:
                 raise ConanException("Wrong cpp_info field '%s' in layout file: %s"
-                                     % (section_name, filepath))
-            if ref:
+                                     % (section_name, self._filepath))
+            if reference:
                 try:
-                    r = ConanFileReference.loads(ref, validate=True)
+                    r = ConanFileReference.loads(reference, validate=True)
                     if r.revision:
                         raise ConanException("Don't provide revision in Editable layouts")
                 except ConanException:
                     raise ConanException("Wrong package reference '%s' in layout file: %s"
-                                         % (ref, filepath))
-            data.setdefault(ref, {})[section_name] = [k for k, _ in parser.items(section)]
-
-        return EditableLayout(data, folders)
-
-    @staticmethod
-    def _work_on_item(value, settings, options):
-        value = value.format(settings=settings, options=options)
-        value = value.replace('\\', '/')
-        return value
+                                         % (reference, self._filepath))
+            data.setdefault(reference, {})[section_name] =\
+                [self._work_on_item(k) for k, _ in parser.items(section)]
+        return data, folders
 
     def apply_to(self, ref, cpp_info, settings=None, options=None):
-        d = self._data
-        data = d.get(str(ref)) or d.get(None) or {}
+        data, _ = self._load_data(ref, settings=settings, options=options)
+
+        # Apply the data to the cpp_info
+        data = data.get(str(ref)) or data.get(None) or {}
 
         try:
             for key, items in data.items():
-                setattr(cpp_info, key, [self._work_on_item(item, settings, options)
-                                        for item in items])
+                setattr(cpp_info, key, items)
         except Exception as e:
             raise ConanException("Error applying layout in '%s': %s" % (str(ref), str(e)))
diff --git a/conans/model/workspace.py b/conans/model/workspace.py
index 761a4ddfc1d..39236c1b7da 100644
--- a/conans/model/workspace.py
+++ b/conans/model/workspace.py
@@ -19,8 +19,7 @@ def __init__(self, base_folder, data, cache, ws_layout, ws_generators, ref):
         self._conanfile_folder = data.pop("path", None)  # The folder with the conanfile
         layout = data.pop("layout", None)
         if layout:
-            self.layout = get_editable_abs_path(layout, self._base_folder,
-                                                cache.conan_folder)
+            self.layout = get_editable_abs_path(layout, self._base_folder, cache.conan_folder)
         else:
             self.layout = ws_layout
 
@@ -52,6 +51,7 @@ def generate(self, cwd, graph, output):
                 ws_pkg = self._workspace_packages[ref]
                 layout = self._cache.package_layout(ref)
                 editable = layout.editable_cpp_info()
+
                 conanfile = node.conanfile
                 build = editable.folder(ref, EditableLayout.BUILD_FOLDER, conanfile.settings,
                                         conanfile.options)
@@ -73,6 +73,7 @@ def generate(self, cwd, graph, output):
                                     % (ref.name, ref.name))
                 else:
                     output.warn("CMake workspace: cannot 'add_subdirectory()'")
+
             if add_subdirs:
                 cmake += "macro(conan_workspace_subdirectories)\n"
                 cmake += add_subdirs
diff --git a/conans/paths/package_layouts/package_editable_layout.py b/conans/paths/package_layouts/package_editable_layout.py
index ae4681605e4..cb48c71b66a 100644
--- a/conans/paths/package_layouts/package_editable_layout.py
+++ b/conans/paths/package_layouts/package_editable_layout.py
@@ -30,7 +30,7 @@ def conanfile(self):
     def editable_cpp_info(self):
         if self._layout_file:
             if os.path.isfile(self._layout_file):
-                return EditableLayout.load(self._layout_file)
+                return EditableLayout(self._layout_file)
             else:
                 raise ConanException("Layout file not found: %s" % self._layout_file)
 
diff --git a/conans/requirements.txt b/conans/requirements.txt
index d787cd05cee..b5c3e9e02ea 100644
--- a/conans/requirements.txt
+++ b/conans/requirements.txt
@@ -13,3 +13,4 @@ pygments>=2.0, <3.0
 astroid>=1.6.5
 deprecation>=2.0, <2.1
 tqdm>=4.28.1, <5
+Jinja2==2.10
diff --git a/conans/test/functional/editable/consume_header_only_test.py b/conans/test/functional/editable/consume_header_only_test.py
index 1879f1d655e..913b2df40e0 100644
--- a/conans/test/functional/editable/consume_header_only_test.py
+++ b/conans/test/functional/editable/consume_header_only_test.py
@@ -2,16 +2,15 @@
 
 import os
 import tempfile
-import unittest
 import textwrap
+import unittest
 
 from parameterized import parameterized
 
-
+from conans.model.editable_cpp_info import DEFAULT_LAYOUT_FILE, LAYOUTS_FOLDER
 from conans.test import CONAN_TEST_FOLDER
 from conans.test.utils.tools import TestClient
 from conans.util.files import save
-from conans.model.editable_cpp_info import DEFAULT_LAYOUT_FILE, LAYOUTS_FOLDER
 
 
 class HeaderOnlyLibTestClient(TestClient):
diff --git a/conans/test/functional/editable/consume_settings_and_options_test.py b/conans/test/functional/editable/consume_settings_and_options_test.py
index df7e1d6bf8c..0d24be61c10 100644
--- a/conans/test/functional/editable/consume_settings_and_options_test.py
+++ b/conans/test/functional/editable/consume_settings_and_options_test.py
@@ -4,12 +4,13 @@
 import os
 import tempfile
 import unittest
+
 from parameterized import parameterized
 
+from conans.model.editable_cpp_info import DEFAULT_LAYOUT_FILE, LAYOUTS_FOLDER
 from conans.test import CONAN_TEST_FOLDER
 from conans.test.utils.tools import TestClient
 from conans.util.files import save
-from conans.model.editable_cpp_info import DEFAULT_LAYOUT_FILE, LAYOUTS_FOLDER
 
 
 class HeaderOnlyLibTestClient(TestClient):
@@ -48,7 +49,7 @@ def package_info(self):
     """
 
     conan_package_layout = """
-[{namespace}includedirs]
+[%sincludedirs]
 src/include/{{settings.build_type}}/{{options.shared}}
 """
 
@@ -67,11 +68,11 @@ def __init__(self, use_repo_file, *args, **kwargs):
                  }
 
         if use_repo_file:
-            files["mylayout"] = self.conan_package_layout.format(namespace="")
+            files["mylayout"] = self.conan_package_layout % ""
         else:
             file_path = os.path.join(self.cache.conan_folder, LAYOUTS_FOLDER, DEFAULT_LAYOUT_FILE)
             save(file_path,
-                 self.conan_package_layout.format(namespace="MyLib/0.1@user/editable:"))
+                 self.conan_package_layout % "MyLib/0.1@user/editable:")
 
         self.save(files)
 
diff --git a/conans/test/functional/editable/layouts_test.py b/conans/test/functional/editable/layouts_test.py
index c8b21f75787..e9b5814ed0a 100644
--- a/conans/test/functional/editable/layouts_test.py
+++ b/conans/test/functional/editable/layouts_test.py
@@ -5,10 +5,10 @@
 import textwrap
 import unittest
 
-from conans.test.utils.tools import TestClient
-from conans.util.files import load, save_files, save
 from conans.model.editable_cpp_info import LAYOUTS_FOLDER
 from conans.test.utils.test_files import temp_folder
+from conans.test.utils.tools import TestClient
+from conans.util.files import load, save_files, save
 
 
 class LayoutTest(unittest.TestCase):
@@ -187,7 +187,7 @@ class Pkg(ConanFile):
             """)
         layout_repo = textwrap.dedent("""
             [includedirs]
-            include_{settings.build_type}
+            include_{{settings.build_type}}
             """)
 
         client.save({"conanfile.py": conanfile,
@@ -200,8 +200,9 @@ class Pkg(ConanFile):
             """)
         client2.save({"conanfile.txt": consumer})
         client2.run("install . -g cmake -s build_type=Debug", assert_error=True)
-        self.assertIn("ERROR: Error applying layout in 'mytool/0.1@user/testing': "
-                      "'settings.build_type' doesn't exist", client2.out)
+        self.assertIn("ERROR: Error parsing layout file '{}' (for reference "
+                      "'mytool/0.1@user/testing')\n'settings.build_type' doesn't exist".format(
+            os.path.join(client.current_folder, 'layout')), client2.out)
 
         # Now add settings to conanfile
         client.save({"conanfile.py": conanfile.replace("pass", 'settings = "build_type"')})
diff --git a/conans/test/integration/workspace_test.py b/conans/test/integration/workspace_test.py
index 52a5d5fdd3a..6e5031be0db 100644
--- a/conans/test/integration/workspace_test.py
+++ b/conans/test/integration/workspace_test.py
@@ -222,13 +222,13 @@ def files(name, depend=None):
             """)
         layout = dedent("""
             [build_folder]
-            build/{settings.build_type}
+            build/{{settings.build_type}}
 
             [includedirs]
             src
 
             [libdirs]
-            build/{settings.build_type}/lib
+            build/{{settings.build_type}}/lib
             """)
         client.save({"conanws.yml": project,
                      "layout": layout})
@@ -295,7 +295,7 @@ def files(name, depend=None):
             """)
         layout = dedent("""
             [build_folder]
-            build/{settings.build_type}
+            build/{{settings.build_type}}
 
             [source_folder]
             src
@@ -304,7 +304,7 @@ def files(name, depend=None):
             src
 
             [libdirs]
-            build/{settings.build_type}/lib
+            build/{{settings.build_type}}/lib
             """)
 
         metacmake = dedent("""
@@ -422,7 +422,7 @@ def files(name, depend=None):
             src
 
             [libdirs]
-            build/{settings.build_type}
+            build/{{settings.build_type}}
             """)
         metacmake = dedent("""
             cmake_minimum_required(VERSION 3.3)
diff --git a/conans/test/unittests/model/editable_cpp_info/apply_test.py b/conans/test/unittests/model/editable_cpp_info/apply_test.py
index 011c2932b0c..12f6775326e 100644
--- a/conans/test/unittests/model/editable_cpp_info/apply_test.py
+++ b/conans/test/unittests/model/editable_cpp_info/apply_test.py
@@ -6,12 +6,11 @@
 import textwrap
 import unittest
 
-from conans.model.editable_cpp_info import EditableLayout
 from conans.model.build_info import CppInfo
+from conans.model.editable_cpp_info import EditableLayout
+from conans.model.ref import ConanFileReference
 from conans.test.utils.test_files import temp_folder
 from conans.util.files import save
-from conans.model.ref import ConanFileReference
-
 
 base_content = textwrap.dedent("""\
     [{namespace}includedirs]
@@ -35,6 +34,7 @@ def setUp(self):
         self.test_folder = temp_folder()
         self.layout_filepath = os.path.join(self.test_folder, "layout")
         self.ref = ConanFileReference.loads("libA/0.1@user/channel")
+        self.editable_cpp_info = EditableLayout(self.layout_filepath)
 
     def tearDown(self):
         shutil.rmtree(self.test_folder)
@@ -42,9 +42,8 @@ def tearDown(self):
     def test_require_no_namespace(self):
         content = base_content.format(namespace="", path_prefix="")
         save(self.layout_filepath, content)
-        editable_cpp_info = EditableLayout.load(self.layout_filepath)
         cpp_info = CppInfo(None)
-        editable_cpp_info.apply_to(self.ref, cpp_info, settings=None, options=None)
+        self.editable_cpp_info.apply_to(self.ref, cpp_info, settings=None, options=None)
         self.assertListEqual(cpp_info.includedirs, ['dirs/includedirs'])
         self.assertListEqual(cpp_info.libdirs, ['dirs/libdirs'])
         self.assertListEqual(cpp_info.resdirs, ['dirs/resdirs'])
@@ -57,9 +56,8 @@ def test_require_namespace(self):
             base_content.format(namespace="libA/0.1@user/channel:", path_prefix="libA/")
             ])
         save(self.layout_filepath, content)
-        editable_cpp_info = EditableLayout.load(self.layout_filepath)
         cpp_info = CppInfo(None)
-        editable_cpp_info.apply_to(self.ref, cpp_info, settings=None, options=None)
+        self.editable_cpp_info.apply_to(self.ref, cpp_info, settings=None, options=None)
         self.assertListEqual(cpp_info.includedirs, ['libA/dirs/includedirs'])
         self.assertListEqual(cpp_info.libdirs, ['libA/dirs/libdirs'])
         self.assertListEqual(cpp_info.resdirs, ['libA/dirs/resdirs'])
@@ -68,7 +66,7 @@ def test_require_namespace(self):
 
         cpp_info = CppInfo(None)
         other = ConanFileReference.loads("other/0.1@user/channel")
-        editable_cpp_info.apply_to(other, cpp_info, settings=None, options=None)
+        self.editable_cpp_info.apply_to(other, cpp_info, settings=None, options=None)
         self.assertListEqual(cpp_info.includedirs, ['dirs/includedirs'])
         self.assertListEqual(cpp_info.libdirs, ['dirs/libdirs'])
         self.assertListEqual(cpp_info.resdirs, ['dirs/resdirs'])
diff --git a/conans/test/unittests/model/editable_cpp_info/load_data_test.py b/conans/test/unittests/model/editable_cpp_info/load_data_test.py
new file mode 100644
index 00000000000..55e1daa3db4
--- /dev/null
+++ b/conans/test/unittests/model/editable_cpp_info/load_data_test.py
@@ -0,0 +1,54 @@
+# coding=utf-8
+
+import os
+import shutil
+import textwrap
+import unittest
+
+from conans.errors import ConanException
+from conans.model.editable_cpp_info import EditableLayout
+from conans.test.utils.test_files import temp_folder
+from conans.util.files import save
+
+
+class ParseTest(unittest.TestCase):
+    def setUp(self):
+        self.test_folder = temp_folder()
+        self.layout_filepath = os.path.join(self.test_folder, "layout")
+        self.editable_cpp_info = EditableLayout(self.layout_filepath)
+
+    def tearDown(self):
+        shutil.rmtree(self.test_folder)
+
+    def test_field_error(self):
+        content = textwrap.dedent("""
+                            [includedrs]
+                            something
+                            """)
+        save(self.layout_filepath, content)
+        with self.assertRaisesRegexp(ConanException, "Wrong cpp_info field 'includedrs' in layout"):
+            _ = self.editable_cpp_info._load_data(ref=None, settings=None, options=None)
+        content = textwrap.dedent("""
+                            [*:includedrs]
+                            something
+                            """)
+        save(self.layout_filepath, content)
+        with self.assertRaisesRegexp(ConanException, "Wrong cpp_info field 'includedrs' in layout"):
+            _ = self.editable_cpp_info._load_data(ref=None, settings=None, options=None)
+
+        content = textwrap.dedent("""
+                            [*:includedirs]
+                            something
+                            """)
+        save(self.layout_filepath, content)
+        with self.assertRaisesRegexp(ConanException, "Wrong package reference '\*' in layout file"):
+            _ = self.editable_cpp_info._load_data(ref=None, settings=None, options=None)
+
+        content = textwrap.dedent("""
+                            [pkg/version@user/channel:revision:includedirs]
+                            something
+                            """)
+        save(self.layout_filepath, content)
+        with self.assertRaisesRegexp(ConanException, "Wrong package reference "
+                                     "'pkg/version@user/channel:revision' in layout file"):
+            _ = self.editable_cpp_info._load_data(ref=None, settings=None, options=None)
diff --git a/conans/test/unittests/model/editable_cpp_info/parse_test.py b/conans/test/unittests/model/editable_cpp_info/parse_test.py
index 1c3ae67a261..babafb36b94 100644
--- a/conans/test/unittests/model/editable_cpp_info/parse_test.py
+++ b/conans/test/unittests/model/editable_cpp_info/parse_test.py
@@ -1,12 +1,14 @@
 # coding=utf-8
 
-import textwrap
 import os
 import shutil
+import textwrap
 import unittest
 
-from conans.errors import ConanException
+from conans.client.conf import default_settings_yml
 from conans.model.editable_cpp_info import EditableLayout
+from conans.model.options import Options, PackageOptions
+from conans.model.settings import Settings
 from conans.test.utils.test_files import temp_folder
 from conans.util.files import save
 
@@ -15,39 +17,45 @@ class ParseTest(unittest.TestCase):
     def setUp(self):
         self.test_folder = temp_folder()
         self.layout_filepath = os.path.join(self.test_folder, "layout")
+        self.editable_cpp_info = EditableLayout(self.layout_filepath)
+
+        self.settings = Settings.loads(default_settings_yml)
+        self.options = Options(PackageOptions({"shared": [True, False]}))
 
     def tearDown(self):
         shutil.rmtree(self.test_folder)
 
-    def field_error_test(self):
-        content = textwrap.dedent("""
-                            [includedrs]
-                            something
-                            """)
-        save(self.layout_filepath, content)
-        with self.assertRaisesRegexp(ConanException, "Wrong cpp_info field 'includedrs' in layout"):
-            _ = EditableLayout.load(self.layout_filepath)
-        content = textwrap.dedent("""
-                            [*:includedrs]
-                            something
-                            """)
-        save(self.layout_filepath, content)
-        with self.assertRaisesRegexp(ConanException, "Wrong cpp_info field 'includedrs' in layout"):
-            _ = EditableLayout.load(self.layout_filepath)
+    def test_render_basic(self):
+        self.options.shared = True
+        self.settings.build_type = "Debug"
 
         content = textwrap.dedent("""
-                            [*:includedirs]
-                            something
-                            """)
+            [includedirs]
+            {% if options.shared %}
+            path/to/shared/{{ settings.build_type }}
+            {% else %}
+            not/expected
+            {% endif %}
+            """)
         save(self.layout_filepath, content)
-        with self.assertRaisesRegexp(ConanException, "Wrong package reference '\*' in layout file"):
-            _ = EditableLayout.load(self.layout_filepath)
+
+        data, folders = self.editable_cpp_info._load_data(ref=None, settings=self.settings,
+                                                          options=self.options)
+        self.assertEqual(data[None], {'includedirs': ["path/to/shared/Debug"]})
+
+    def test_render_loop(self):
+        self.settings.build_type = "Debug"
 
         content = textwrap.dedent("""
-                            [pkg/version@user/channel:revision:includedirs]
-                            something
-                            """)
+            [includedirs]
+            {% for item in ["cmp1", "cmp2", "cmp3"] %}
+            components\{{ item }}\include\{% if item != "cmp3" %}{{ settings.build_type }}{% endif %}
+            {% endfor %}
+            """)
         save(self.layout_filepath, content)
-        with self.assertRaisesRegexp(ConanException, "Wrong package reference "
-                                     "'pkg/version@user/channel:revision' in layout file"):
-            _ = EditableLayout.load(self.layout_filepath)
+
+        data, folders = self.editable_cpp_info._load_data(ref=None, settings=self.settings,
+                                                          options=self.options)
+        self.assertEqual(data[None], {'includedirs': ["components/cmp1/include/Debug",
+                                                      "components/cmp2/include/Debug",
+                                                      "components/cmp3/include/"]})
diff --git a/conans/test/unittests/model/editable_cpp_info/work_on_items_test.py b/conans/test/unittests/model/editable_cpp_info/work_on_items_test.py
deleted file mode 100644
index 39d09e3ef85..00000000000
--- a/conans/test/unittests/model/editable_cpp_info/work_on_items_test.py
+++ /dev/null
@@ -1,36 +0,0 @@
-# coding=utf-8
-
-import unittest
-
-from conans.client.conf import default_settings_yml
-from conans.model.editable_cpp_info import EditableLayout
-from conans.model.settings import Settings
-
-
-class WorkOnItemsTest(unittest.TestCase):
-
-    def test_empty(self):
-        self.assertEqual("", EditableLayout._work_on_item("", None, None))
-
-    def test_placeholders(self):
-        settings = Settings.loads(default_settings_yml)
-        settings.compiler = 'Visual Studio'
-        settings.compiler.version = '14'
-        settings.build_type = 'Debug'
-
-        self.assertEqual('src/Visual Studio14/Debug/include',
-                         EditableLayout._work_on_item("src/{settings.compiler}{settings.compiler.version}/{settings.build_type}/include",
-                                                       settings=settings,
-                                                       options=None))
-        self.assertEqual('C:/Visual Studio/include/',
-                         EditableLayout._work_on_item("C:\\{settings.compiler}\\include\\",
-                                                       settings=settings,
-                                                       options=None))
-        self.assertEqual('C:/Visual Studio/include/',
-                         EditableLayout._work_on_item("C:\{settings.compiler}\include\\",
-                                                       settings=settings,
-                                                       options=None))
-        self.assertEqual('/usr/path with spaces/Visual Studio/dir',
-                         EditableLayout._work_on_item("/usr/path with spaces/{settings.compiler}/dir",
-                                                       settings=settings,
-                                                       options=None))
diff --git a/conans/test/utils/tools.py b/conans/test/utils/tools.py
index 8fcc489c2bb..6e6a957107c 100644
--- a/conans/test/utils/tools.py
+++ b/conans/test/utils/tools.py
@@ -3,24 +3,24 @@
 import os
 import random
 import shlex
+import shutil
 import stat
 import subprocess
 import sys
 import tempfile
+import threading
+import time
 import unittest
+import uuid
 from collections import Counter, OrderedDict
 from contextlib import contextmanager
-from io import StringIO
 
 import bottle
 import nose
 import requests
-import shutil
 import six
-import threading
-import time
-import uuid
 from mock import Mock
+from six import StringIO
 from six.moves.urllib.parse import quote, urlsplit, urlunsplit
 from webtest.app import TestApp
 
diff --git a/conans/util/templates.py b/conans/util/templates.py
new file mode 100644
index 00000000000..ec8b3875a15
--- /dev/null
+++ b/conans/util/templates.py
@@ -0,0 +1,8 @@
+# coding=utf-8
+
+from jinja2 import Template
+
+
+def render_layout_file(content, ref=None, settings=None, options=None):
+    t = Template(content)
+    return t.render(reference=str(ref), settings=settings, options=options)


Code review comments:
diff_hunk: @@ -13,3 +13,4 @@ pygments>=2.0, <3.0
 astroid>=1.6.5
 deprecation>=2.0, <2.1
 tqdm>=4.28.1, <5
+Jinja2==2.10
path: conans/requirements.txt
text: @user1:
Maybe this requirement could be more relaxed? So it minimizes collisions with other possible Jinja2 previously installed.

@author:
Upps, yes, didn't realize about it after adding it to the requirements.

Quick testing: `sphinx` requires `>=2.3` and our tests are working with it... and Jinja2.3 is from Feb 10, 2010. Maybe go for `Jinja2>=2.3, <3`?