Repository: easybuilders/easybuild-framework

Issue:
Misplaced comments after parsing and dumping of recipes
When Easybuild parses and dumps an easyconfig it can sometimes misplace or remove comments. Two examples are the following.

### `CDO-1.9.5-CrayGNU-19.09.eb`
Original easyconfig 
```
easyblock = 'ConfigureMake'

name = 'CDO'
version = '1.9.5'

homepage = 'https://code.zmaw.de/projects/cdo'
description = """
    CDO is a collection of command line Operators to manipulate and analyse
    Climate and NWP model Data.
"""

toolchain = {'name': 'CrayGNU', 'version': '19.09'}
toolchainopts = {'opt': True, 'pic': True}

# Files visible at https://code.mpimet.mpg.de/projects/cdo/files,
# however link is different for each version!
source_urls = ['https://code.mpimet.mpg.de/attachments/download/18264/']
sources = [SOURCELOWER_TAR_GZ]
checksums = [ '0c60f2c94dc5c76421ecf363153a5043' ]

dependencies = [
    ('cray-netcdf/4.6.3.1', EXTERNAL_MODULE),
    ('cray-hdf5/1.10.5.1', EXTERNAL_MODULE),
    # CDO: OpenMP support for compute intensive operators:
    # https://code.zmaw.de/projects/cdo/wiki/OpenMP_support
    # No MPI support
]


preconfigopts = 'export NC_CONFIG=`which nc-config` && '

configopts = "--with-hdf5=$HDF5_DIR --with-netcdf=$EBROOTNETCDFMINFORTRAN "

sanity_check_paths = {
    'files': ["bin/cdo"],
    'dirs': [],
}

```

And the same easyconfig after eb has parsed and dumped it. Comments inside dependencies are removed, except for the last line that goes after checksums.
```
easyblock = 'ConfigureMake'

name = 'CDO'
version = '1.9.5'

homepage = 'https://code.zmaw.de/projects/cdo'
description = """
    CDO is a collection of command line Operators to manipulate and analyse
    Climate and NWP model Data.
"""

toolchain = {'name': 'CrayGNU', 'version': '19.09'}
toolchainopts = {'opt': True, 'pic': True}

# Files visible at https://code.mpimet.mpg.de/projects/cdo/files,
# however link is different for each version!
source_urls = ['https://code.mpimet.mpg.de/attachments/download/18264/']
sources = [SOURCELOWER_TAR_GZ]
checksums = ['0c60f2c94dc5c76421ecf363153a5043']  # No MPI support

dependencies = [
    ('cray-netcdf/4.6.3.1', EXTERNAL_MODULE),
    ('cray-hdf5/1.10.5.1', EXTERNAL_MODULE),
]

preconfigopts = "export NC_CONFIG=`which nc-config` && "
configopts = "--with-hdf5=$HDF5_DIR --with-netcdf=$EBROOTNETCDFMINFORTRAN "


sanity_check_paths = {
    'files': ['bin/%(namelower)s'],
    'dirs': [],
}

moduleclass = 'data'
```

### `CrayCCE-19.09-classic.eb`
Original easyconfig
```
easyblock = 'CrayToolchain'

name = 'CrayCCE'
version = '19.09-classic'

homepage = 'https://pubs.cray.com/discover'
description = """Toolchain using Cray compiler wrapper, using PrgEnv-cray
(PE release: September 2019).\n"""

toolchain = SYSTEM

dependencies = [
    # PrgEnv version is not pinned, as Cray recommends to use the latest
    # (default) version
    ('PrgEnv-cray', EXTERNAL_MODULE),
    ('atp/2.1.3', EXTERNAL_MODULE),
    ('cce/9.0.2-classic', EXTERNAL_MODULE),
    ('modules/3.2.11.3', EXTERNAL_MODULE),
    ('cray-libsci/19.06.1', EXTERNAL_MODULE),
    ('cray-mpich/7.7.10', EXTERNAL_MODULE),
    ('craype/2.6.1', EXTERNAL_MODULE),
    ('pmi/5.0.14', EXTERNAL_MODULE),
]

# LD_LIBRARY_PATH is now updated by production.git/login/daint.footer

moduleclass = 'toolchain'
```
Similarly the comments inside dependencies are moved or removed. Dumped file:
```
easyblock = 'CrayToolchain'

name = 'CrayCCE'
version = '19.09-classic'

homepage = 'https://pubs.cray.com/discover'
description = """Toolchain using Cray compiler wrapper, using PrgEnv-cray
(PE release: September 2019).
"""

toolchain = {'name': 'system', 'version': 'system'}

dependencies = [
    ('PrgEnv-cray', EXTERNAL_MODULE),
    ('atp/2.1.3', EXTERNAL_MODULE),
    ('cce/9.0.2-classic', EXTERNAL_MODULE),
    ('modules/3.2.11.3', EXTERNAL_MODULE),
    ('cray-libsci/19.06.1', EXTERNAL_MODULE),
    ('cray-mpich/7.7.10', EXTERNAL_MODULE),
    ('craype/2.6.1', EXTERNAL_MODULE),
    ('pmi/5.0.14', EXTERNAL_MODULE),
]  # (default) version


# LD_LIBRARY_PATH is now updated by production.git/login/daint.footer
moduleclass = 'toolchain'
```


Discussion:
Thanks for the detailed bug report @ekouts!

I'll try and look into this soon, but this may be a hairy one to fix...

Resolving patch:
diff --git a/easybuild/framework/easyconfig/format/one.py b/easybuild/framework/easyconfig/format/one.py
--- a/easybuild/framework/easyconfig/format/one.py
+++ b/easybuild/framework/easyconfig/format/one.py
@@ -31,6 +31,7 @@
 :author: Kenneth Hoste (Ghent University)
 """
 import os
+import pprint
 import re
 import tempfile
 
@@ -150,12 +151,14 @@ def _reformat_line(self, param_name, param_val, outer=False, addlen=0):
 
         elif outer:
             # only reformat outer (iterable) values for (too) long lines (or for select parameters)
-            if isinstance(param_val, (list, tuple, dict)) and ((len(param_val) > 1 and line_too_long) or forced):
+            if isinstance(param_val, (list, tuple, dict)) and ((len(param_val) > 1 or line_too_long) or forced):
 
-                item_tmpl = INDENT_4SPACES + '%(item)s,%(comment)s\n'
+                item_tmpl = INDENT_4SPACES + '%(item)s,%(inline_comment)s\n'
+
+                start_char, end_char = param_strval[0], param_strval[-1]
 
                 # start with opening character: [, (, {
-                res = '%s\n' % param_strval[0]
+                res = '%s\n' % start_char
 
                 # add items one-by-one, special care for dict values (order of keys, different format for elements)
                 if isinstance(param_val, dict):
@@ -166,18 +169,32 @@ def _reformat_line(self, param_name, param_val, outer=False, addlen=0):
                         else:
                             raise EasyBuildError("Missing mandatory key '%s' in %s.", item_key, param_name)
 
-                        comment = self._get_item_comments(param_name, item_val).get(str(item_val), '')
+                        item_comments = self._get_item_comments(param_name, item_val)
+
+                        inline_comment = item_comments.get('inline', '')
+                        item_tmpl_dict = {'inline_comment': inline_comment}
+
+                        for comment in item_comments.get('above', []):
+                            res += INDENT_4SPACES + comment + '\n'
+
                         key_pref = quote_py_str(item_key) + ': '
-                        addlen = addlen + len(INDENT_4SPACES) + len(key_pref) + len(comment)
+                        addlen = addlen + len(INDENT_4SPACES) + len(key_pref) + len(inline_comment)
                         formatted_item_val = self._reformat_line(param_name, item_val, addlen=addlen)
-                        res += item_tmpl % {
-                            'comment': comment,
-                            'item': key_pref + formatted_item_val,
-                        }
+                        item_tmpl_dict['item'] = key_pref + formatted_item_val
+
+                        res += item_tmpl % item_tmpl_dict
+
                 else:  # list, tuple
                     for item in param_val:
-                        comment = self._get_item_comments(param_name, item).get(str(item), '')
-                        addlen = addlen + len(INDENT_4SPACES) + len(comment)
+                        item_comments = self._get_item_comments(param_name, item)
+
+                        inline_comment = item_comments.get('inline', '')
+                        item_tmpl_dict = {'inline_comment': inline_comment}
+
+                        for comment in item_comments.get('above', []):
+                            res += INDENT_4SPACES + comment + '\n'
+
+                        addlen = addlen + len(INDENT_4SPACES) + len(inline_comment)
                         # the tuples are really strings here that are constructed from the dependency dicts
                         # so for a plain list of builddependencies param_val is a list of strings here;
                         # and for iterated builddependencies it is a list of lists of strings
@@ -188,14 +205,20 @@ def _reformat_line(self, param_name, param_val, outer=False, addlen=0):
                                 for subitem in item]) + ']'
                         else:
                             itemstr = self._reformat_line(param_name, item, addlen=addlen)
+                        item_tmpl_dict['item'] = itemstr
+
+                        res += item_tmpl % item_tmpl_dict
 
-                        res += item_tmpl % {
-                            'comment': comment,
-                            'item': itemstr
-                        }
+                # take into account possible closing comments
+                # see https://github.com/easybuilders/easybuild-framework/issues/3082
+                end_comments = self._get_item_comments(param_name, end_char)
+                for comment in end_comments.get('above', []):
+                    res += INDENT_4SPACES + comment + '\n'
 
-                # end with closing character: ], ), }
-                res += param_strval[-1]
+                # end with closing character (']', ')', '}'), incl. possible inline comment
+                res += end_char
+                if 'inline' in end_comments:
+                    res += end_comments['inline']
 
         else:
             # dependencies are already dumped as strings, so they do not need to be quoted again
@@ -207,9 +230,14 @@ def _reformat_line(self, param_name, param_val, outer=False, addlen=0):
     def _get_item_comments(self, key, val):
         """Get per-item comments for specified parameter name/value."""
         item_comments = {}
-        for comment_key, comment_val in self.comments['iter'].get(key, {}).items():
+
+        for comment_key, comment_val in self.comments['iterabove'].get(key, {}).items():
+            if str(val) in comment_key:
+                item_comments['above'] = comment_val
+
+        for comment_key, comment_val in self.comments['iterinline'].get(key, {}).items():
             if str(val) in comment_key:
-                item_comments[str(val)] = comment_val
+                item_comments['inline'] = comment_val
 
         return item_comments
 
@@ -312,64 +340,138 @@ def extract_comments(self, rawtxt):
         Inline comments on items of iterable values are also extracted.
         """
         self.comments = {
-            'above': {},  # comments for a particular parameter definition
+            'above': {},  # comments above a parameter definition
             'header': [],  # header comment lines
             'inline': {},  # inline comments
-            'iter': {},  # (inline) comments on elements of iterable values
-            'tail': [],
+            'iterabove': {},  # comment above elements of iterable values
+            'iterinline': {},  # inline comments on elements of iterable values
+            'tail': [],  # comment at the end of the easyconfig file
          }
 
-        rawlines = rawtxt.split('\n')
+        parsed_ec = self.get_config_dict()
 
-        # extract header first
-        while rawlines and rawlines[0].startswith('#'):
-            self.comments['header'].append(rawlines.pop(0))
+        comment_regex = re.compile(r'^\s*#')
+        param_def_regex = re.compile(r'^([a-z_0-9]+)\s*=')
+        whitespace_regex = re.compile(r'^\s*$')
 
-        parsed_ec = self.get_config_dict()
+        def clean_part(part):
+            """Helper function to strip off trailing whitespace + trailing quotes."""
+            return part.rstrip().rstrip("'").rstrip('"')
+
+        def split_on_comment_hash(line, param_key):
+            """Helper function to split line on first (actual) comment character '#'."""
+
+            # string representation of easyconfig parameter value,
+            # used to check if supposed comment isn't actual part of the parameter value
+            # (and thus not actually a comment at all)
+            param_strval = str(parsed_ec.get(param_key))
+
+            parts = line.split('#')
+
+            # first part (before first #) is definitely not part of comment
+            before_comment = parts.pop(0)
 
+            # strip out parts that look like a comment but are actually part of a parameter value
+            while parts and ('#' + clean_part(parts[0])) in param_strval:
+                before_comment += '#' + parts.pop(0)
+
+            comment = '#'.join(parts)
+
+            return before_comment, comment.strip()
+
+        def grab_more_comment_lines(lines, param_key):
+            """Grab more comment lines."""
+
+            comment_lines = []
+
+            while lines and (comment_regex.match(lines[0]) or whitespace_regex.match(lines[0])):
+                line = lines.pop(0)
+                _, actual_comment = split_on_comment_hash(line, param_key)
+                # prefix comment with '#' unless line was empty
+                if line.strip():
+                    actual_comment = '# ' + actual_comment
+                comment_lines.append(actual_comment.strip())
+
+            return comment_lines
+
+        rawlines = rawtxt.split('\n')
+
+        # extract header first (include empty lines too)
+        self.comments['header'] = grab_more_comment_lines(rawlines, None)
+
+        last_param_key = None
         while rawlines:
             rawline = rawlines.pop(0)
+
+            # keep track of last parameter definition we have seen,
+            # current line may be (the start of) a parameter definition
+            res = param_def_regex.match(rawline)
+            if res:
+                key = res.group(1)
+                if key in parsed_ec:
+                    last_param_key = key
+
+            if last_param_key:
+                before_comment, inline_comment = split_on_comment_hash(rawline, last_param_key)
+
+                # short-circuit to next line in case there are no actual comments on this (non-empty) line
+                if before_comment and not inline_comment:
+                    continue
+
+            # lines that start with a hash indicate (start of a block of) comment line(s)
             if rawline.startswith('#'):
-                comment = []
-                # comment could be multi-line
-                while rawline is not None and (rawline.startswith('#') or not rawline):
-                    # drop empty lines (that don't even include a #)
-                    if rawline:
-                        comment.append(rawline)
-                    # grab next line (if more lines are left)
-                    if rawlines:
-                        rawline = rawlines.pop(0)
+                comment = [rawline] + grab_more_comment_lines(rawlines, last_param_key)
+
+                if rawlines:
+                    # try to pin comment to parameter definition below it
+                    # don't consume the line yet though, it may also include inline comments...
+                    res = param_def_regex.match(rawlines[0])
+                    if res:
+                        last_param_key = res.group(1)
+                        self.comments['above'][last_param_key] = comment
                     else:
-                        rawline = None
-
-                if rawline is None:
-                    self.comments['tail'] = comment
+                        # if the comment is not above a parameter definition,
+                        # then it must be a comment for an item of an iterable parameter value
+                        before_comment, _ = split_on_comment_hash(rawlines[0], last_param_key)
+                        comment_key = before_comment.rstrip()
+                        self.comments['iterabove'].setdefault(last_param_key, {})[comment_key] = comment
                 else:
-                    key = rawline.split('=', 1)[0].strip()
-                    self.comments['above'][key] = comment
-
-            elif '#' in rawline:  # inline comment
-                comment_key, comment_val = None, None
-                comment = rawline.rsplit('#', 1)[1].strip()
-                # check whether this line is parameter definition;
-                # if not, assume it's a continuation of a multi-line value
-                if re.match(r'^[a-z_]+\s*=', rawline):
-                    comment_key = rawline.split('=', 1)[0].strip()
+                    # if there are no more lines, the comment (block) is at the tail
+                    self.comments['tail'] = comment
+
+            elif '#' in rawline:
+                # if there's a hash character elsewhere in the line (not at the start),
+                # there are a couple of possibilities:
+                # - inline comment for a parameter definition (at the end of a non-empty line)
+                # - indented comment for an item value of an iterable easyconfig parameter (list, dict, ...)
+                # - inline comment for an item value of an iterable easyconfig parameter
+
+                before_comment, comment = split_on_comment_hash(rawline, last_param_key)
+                comment = ('# ' + comment).rstrip()
+
+                # first check whether current line is an easyconfig parameter definition
+                # if so, the comment is an inline comment
+                if param_def_regex.match(before_comment):
+                    self.comments['inline'][last_param_key] = '  ' + comment
+
+                # if there's only whitespace before the comment,
+                # then we have an indented comment, and we need to figure out for what exactly
+                elif whitespace_regex.match(before_comment):
+                    # first consume possible additional comment lines with same indentation
+                    comment = [comment] + grab_more_comment_lines(rawlines, last_param_key)
+
+                    before_comment, inline_comment = split_on_comment_hash(rawlines.pop(0), last_param_key)
+                    comment_key = before_comment.rstrip()
+                    self.comments['iterabove'].setdefault(last_param_key, {})[comment_key] = comment
+                    if inline_comment:
+                        inline_comment = ('  # ' + inline_comment).rstrip()
+                        self.comments['iterinline'].setdefault(last_param_key, {})[comment_key] = inline_comment
                 else:
-                    # determine parameter value where the item value on this line is a part of
-                    for key, val in parsed_ec.items():
-                        item_val = re.sub(r',$', r'', rawline.rsplit('#', 1)[0].strip())
-                        if not isinstance(val, string_type) and item_val in str(val):
-                            comment_key, comment_val = key, item_val
-                            break
-
-                # check if hash actually indicated a comment; or is part of the value
-                if comment_key in parsed_ec:
-                    if comment.replace("'", '').replace('"', '') not in str(parsed_ec[comment_key]):
-                        if comment_val:
-                            self.comments['iter'].setdefault(comment_key, {})[comment_val] = '  # ' + comment
-                        else:
-                            self.comments['inline'][comment_key] = '  # ' + comment
+                    # inline comment for item of iterable value
+                    comment_key = before_comment.rstrip()
+                    self.comments['iterinline'].setdefault(last_param_key, {})[comment_key] = '  ' + comment
+
+        self.log.debug("Extracted comments:\n%s", pprint.pformat(self.comments, width=120))
 
 
 def retrieve_blocks_in_spec(spec, only_blocks, silent=False):


Test patch:
diff --git a/test/framework/easyconfig.py b/test/framework/easyconfig.py
--- a/test/framework/easyconfig.py
+++ b/test/framework/easyconfig.py
@@ -1852,6 +1852,23 @@ def test_dump_comments(self):
         os.close(handle)
 
         ec = EasyConfig(None, rawtxt=rawtxt)
+
+        # check internal structure to keep track of comments
+        self.assertEqual(ec.parser._formatter.comments['above'], {
+            'homepage': ["# comment on the homepage"],
+            'toolchain': ['# toolchain comment', ''],
+        })
+        self.assertEqual(ec.parser._formatter.comments['header'], ['# #', '# some header comment', '# #'])
+        self.assertEqual(ec.parser._formatter.comments['inline'], {
+            'description': '  # test',
+            'name': "  # name comment",
+        })
+        self.assertEqual(ec.parser._formatter.comments['iterabove'], {})
+        self.assertEqual(ec.parser._formatter.comments['iterinline'], {
+            'sanity_check_paths': {"    'files': ['files/foobar'],": "  # comment on files"},
+        })
+        self.assertEqual(ec.parser._formatter.comments['tail'], ["# trailing comment"])
+
         ec.dump(testec)
         ectxt = read_file(testec)
 
@@ -1860,11 +1877,10 @@ def test_dump_comments(self):
             r"name = 'Foo'  # name comment",
             r"# comment on the homepage\nhomepage = 'http://foo.com/'",
             r'description = "foo description with a # in it"  # test',
-            r"# toolchain comment\ntoolchain = {",
+            r"# toolchain comment\n\ntoolchain = {",
             r"    'files': \['files/foobar'\],  # comment on files",
             r"    'dirs': \[\],",
         ]
-
         for pattern in patterns:
             regex = re.compile(pattern, re.M)
             self.assertTrue(regex.search(ectxt), "Pattern '%s' found in: %s" % (regex.pattern, ectxt))
@@ -1876,6 +1892,244 @@ def test_dump_comments(self):
 
         check_easyconfigs_style([testec])
 
+        # another, more extreme example
+        # inspired by https://github.com/easybuilders/easybuild-framework/issues/3082
+        write_file(testec, '')
+        rawtxt = '\n'.join([
+            "# this is a header",
+            "#",
+            "# which may include empty comment lines",
+            "    # weirdly indented lines",
+            '  ',  # whitespace-only line, should get stripped (but no # added)
+            "# or flat out empty lines",
+            '',
+            "easyblock = 'ConfigureMake'",
+            '',
+            "name = 'thisisjustatest'  # just a test",
+            "# the version doesn't matter much here",
+            "version = '1.2.3'",
+            '',
+            "homepage = 'https://example.com'  # may not be actual homepage",
+            "description = \"\"\"Sneaky description with hashes, line #1",
+            " line #2",
+            " line without hashes",
+            "#4 (yeah, sneaky, isn't it),",
+            "end line",
+            "\"\"\"",
+            '',
+            "toolchain = SYSTEM",
+            "# after toolchain, before sources comment",
+            '',
+            "# this comment contains another #, uh-oh...",
+            "sources = ['test-1.2.3.tar.gz']",
+            "# how about # a comment with # multple additional hashes",
+            "source_urls = [",
+            "    # first possible source URL",
+            "    'https://example.com',",
+            "# annoying non-indented comment",
+            "    'https://anotherexample.com',  # fallback URL",
+            "]",
+            '',
+            "# this is a multiline comment above dependencies",
+            "# I said multiline",
+            "# multi > 3",
+            "dependencies = [",
+            "    # this dependency",
+            "# has multiple lines above it",
+            "    # some of which without proper indentation...",
+            "    ('foo', '1.2.3'),  # and an inline comment too",
+            "    ('nocomment', '4.5'),",
+            "    # last dependency, I promise",
+            "    ('last', '1.2.3'),",
+            "    # trailing comments in dependencies",
+            "    # a bit weird, but it happens",
+            "]  # inline comment after closing dependencies",
+            '',
+            "# how about comments above and in a dict value?",
+            "sanity_check_paths = {",
+            "    # a bunch of files",
+            "    'files': ['bin/foo', 'lib/libfoo.a'],",
+            "    # no dirs!",
+            "    'dirs': [],",
+            "}",
+            '',
+            "moduleclass = 'tools'",
+            "#",
+            "# trailing comment",
+            '',
+            "# with an empty line in between",
+            "# DONE!",
+        ])
+        ec = EasyConfig(None, rawtxt=rawtxt)
+
+        # check internal structure to keep track of comments
+        self.assertEqual(ec.parser._formatter.comments['above'], {
+           'dependencies': [
+               '# this is a multiline comment above dependencies',
+               '# I said multiline',
+               '# multi > 3',
+           ],
+           'sanity_check_paths': ['# how about comments above and in a dict value?'],
+           'source_urls': ['# how about # a comment with # multple additional hashes'],
+           'sources': ['# after toolchain, before sources comment',
+                       '',
+                       '# this comment contains another #, uh-oh...'],
+           'version': ["# the version doesn't matter much here"],
+        })
+        self.assertEqual(ec.parser._formatter.comments['header'], [
+            '# this is a header',
+            '#',
+            '# which may include empty comment lines',
+            '# weirdly indented lines',
+            '',
+            '# or flat out empty lines',
+            '',
+        ])
+        self.assertEqual(ec.parser._formatter.comments['inline'], {
+            'homepage': '  # may not be actual homepage',
+            'name': '  # just a test',
+        })
+        self.assertEqual(ec.parser._formatter.comments['iterabove'], {
+            'dependencies': {
+                "    ('foo', '1.2.3'),": ['# this dependency',
+                                          '# has multiple lines above it',
+                                          "# some of which without proper indentation..."],
+                "    ('last', '1.2.3'),": ['# last dependency, I promise'],
+                ']': ['# trailing comments in dependencies', '# a bit weird, but it happens'],
+            },
+            'sanity_check_paths': {
+                "    'dirs': [],": ['# no dirs!'],
+                "    'files': ['bin/foo', 'lib/libfoo.a'],": ['# a bunch of files'],
+            },
+            'source_urls': {
+                "    'https://example.com',": ['# first possible source URL'],
+                "    'https://anotherexample.com',": ['# annoying non-indented comment'],
+            },
+        })
+        self.assertEqual(ec.parser._formatter.comments['iterinline'], {
+            'dependencies': {
+                "    ('foo', '1.2.3'),": '  # and an inline comment too',
+                ']': "  # inline comment after closing dependencies",
+            },
+            'source_urls': {
+                "    'https://anotherexample.com',": '  # fallback URL',
+            },
+        })
+        self.assertEqual(ec.parser._formatter.comments['tail'], [
+            '#',
+            '# trailing comment',
+            '',
+            '# with an empty line in between',
+            '# DONE!',
+        ])
+
+        ec.dump(testec)
+        ectxt = read_file(testec)
+
+        # reparsing the dumped easyconfig file should work
+        EasyConfig(testec)
+
+        check_easyconfigs_style([testec])
+
+        self.assertTrue(ectxt.startswith('\n'.join([
+            '# this is a header',
+            '#',
+            '# which may include empty comment lines',
+            '# weirdly indented lines',
+            '',
+            '# or flat out empty lines',
+            '',
+        ])))
+
+        patterns = [
+            # inline comments
+            r"^homepage = .*  # may not be actual homepage\n",
+            r"^name = .*  # just a test",
+            r"^    \('foo', '1\.2\.3'\),  # and an inline comment too",
+            r"^    'https://anotherexample\.com',  # fallback URL",
+            # comments above parameter definition
+            '\n'.join([
+                r'',
+                r"# this is a multiline comment above dependencies",
+                r"# I said multiline",
+                r"# multi > 3",
+                r"dependencies = ",
+            ]),
+            '\n'.join([
+                r'',
+                r"# how about comments above and in a dict value\?",
+                r"sanity_check_paths = ",
+            ]),
+            '\n'.join([
+                r'',
+                r"# how about # a comment with # multple additional hashes",
+                r"source_urls = ",
+            ]),
+            '\n'.join([
+                r'',
+                r"# after toolchain, before sources comment",
+                r'',
+                r"# this comment contains another #, uh-oh...",
+                r"sources = ",
+            ]),
+            '\n'.join([
+                r'',
+                r"# the version doesn't matter much here",
+                r"version = ",
+            ]),
+            '\n'.join([
+                r'',
+                r"    # trailing comments in dependencies",
+                r"    # a bit weird, but it happens",
+                r"\]  # inline comment after closing dependencies",
+            ]),
+            # comments above element of iterable parameter value
+            '\n'.join([
+                r'',
+                r"    # this dependency",
+                r"    # has multiple lines above it",
+                r"    # some of which without proper indentation\.\.\.",
+                r"    \('foo', '1\.2\.3'\),  # and an inline comment too",
+            ]),
+            '\n'.join([
+                r'',
+                r"    # last dependency, I promise",
+                r"    \('last', '1\.2\.3'\),"
+            ]),
+            '\n'.join([
+                '',
+                r"    # no dirs\!",
+                r"    'dirs': \[\],"
+            ]),
+            '\n'.join([
+                '',
+                r"    # a bunch of files",
+                r"    'files': \['bin/foo', 'lib/libfoo\.a'\],",
+            ]),
+            '\n'.join([
+                '',
+                r"    # first possible source URL",
+                r"    'https://example\.com',",
+            ]),
+            '\n'.join([
+                '',
+                r"    # annoying non-indented comment",
+                r"    'https://anotherexample\.com',  # fallback URL",
+            ]),
+        ]
+        for pattern in patterns:
+            regex = re.compile(pattern, re.M)
+            self.assertTrue(regex.search(ectxt), "Pattern '%s' found in: %s" % (regex.pattern, ectxt))
+
+        self.assertTrue(ectxt.endswith('\n'.join([
+            '#',
+            '# trailing comment',
+            '',
+            '# with an empty line in between',
+            '# DONE!',
+            '',
+        ])))
+
     def test_to_template_str(self):
         """ Test for to_template_str method """