Skip to content

Commit c7a3777

Browse files
committed
Merge pull request easybuilders#1327 from Caylo/dump-comments
Preserve comments in dump() method
2 parents 193f4a9 + 4aacbbf commit c7a3777

3 files changed

Lines changed: 148 additions & 29 deletions

File tree

easybuild/framework/easyconfig/easyconfig.py

Lines changed: 45 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
import easybuild.tools.environment as env
4747
from easybuild.tools.build_log import EasyBuildError
4848
from easybuild.tools.config import build_option, get_module_naming_scheme
49-
from easybuild.tools.filetools import decode_class_name, encode_class_name, read_file
49+
from easybuild.tools.filetools import decode_class_name, encode_class_name, read_file, write_file
5050
from easybuild.tools.module_naming_scheme import DEVEL_MODULE_SUFFIX
5151
from easybuild.tools.module_naming_scheme.utilities import avail_module_naming_schemes, det_full_ec_version
5252
from easybuild.tools.module_naming_scheme.utilities import det_hidden_modname, is_valid_module_name
@@ -78,6 +78,24 @@
7878
# values for these keys will not be templated in dump()
7979
EXCLUDED_KEYS_REPLACE_TEMPLATES = ['easyblock', 'name', 'version', 'description', 'homepage', 'toolchain']
8080

81+
82+
# ordered groups of keys to obtain a nice looking easyconfig file
83+
GROUPED_PARAMS = [
84+
['easyblock'],
85+
['name', 'version', 'versionprefix', 'versionsuffix'],
86+
['homepage', 'description'],
87+
['toolchain', 'toolchainopts'],
88+
['sources', 'source_urls'],
89+
['patches'],
90+
['builddependencies', 'dependencies', 'hiddendependencies'],
91+
['osdependencies'],
92+
['preconfigopts', 'configopts'],
93+
['prebuildopts', 'buildopts'],
94+
['preinstallopts', 'installopts'],
95+
['parallel', 'maxparallel'],
96+
]
97+
LAST_PARAMS = ['sanity_check_paths', 'moduleclass']
98+
8199
_easyconfig_files_cache = {}
82100
_easyconfigs_cache = {}
83101

@@ -246,6 +264,8 @@ def parse(self):
246264
local_vars = parser.get_config_dict()
247265
self.log.debug("Parsed easyconfig as a dictionary: %s" % local_vars)
248266

267+
self.comments = parser.get_comments()
268+
249269
# make sure all mandatory parameters are defined
250270
# this includes both generic mandatory parameters and software-specific parameters defined via extra_options
251271
missing_mandatory_keys = [key for key in self.mandatory if key not in local_vars]
@@ -475,25 +495,6 @@ def dump(self, fp):
475495
"""
476496
Dump this easyconfig to file, with the given filename.
477497
"""
478-
eb_file = file(fp, 'w')
479-
480-
# ordered groups of keys to obtain a nice looking easyconfig file
481-
grouped_keys = [
482-
['easyblock'],
483-
['name', 'version', 'versionprefix', 'versionsuffix'],
484-
['homepage', 'description'],
485-
['toolchain', 'toolchainopts'],
486-
['sources', 'source_urls'],
487-
['patches'],
488-
['builddependencies', 'dependencies', 'hiddendependencies'],
489-
['osdependencies'],
490-
['preconfigopts', 'configopts'],
491-
['prebuildopts', 'buildopts'],
492-
['preinstallopts', 'installopts'],
493-
['parallel', 'maxparallel'],
494-
]
495-
496-
last_keys = ['sanity_check_paths', 'moduleclass']
497498

498499
orig_enable_templating = self.enable_templating
499500
self.enable_templating = False # templated values should be dumped unresolved
@@ -509,6 +510,17 @@ def dump(self, fp):
509510
keys = sorted(self.template_values, key=lambda k: len(self.template_values[k]), reverse=True)
510511
templ_val = OrderedDict([(self.template_values[k], k) for k in keys if len(self.template_values[k]) > 2])
511512

513+
def add_key_and_comments(key, val):
514+
"""
515+
Add key + value and comments (if any) to txt to be dumped.
516+
"""
517+
if key in self.comments['inline']:
518+
ebtxt.append("%s = %s %s" % (key, val, self.comments['inline'][key]))
519+
else:
520+
if key in self.comments['above']:
521+
ebtxt.extend(self.comments['above'][key])
522+
ebtxt.append("%s = %s" % (key, val))
523+
512524
def include_defined_parameters(keyset):
513525
"""
514526
Internal function to include parameters in the dumped easyconfig file which have a non-default value.
@@ -537,29 +549,33 @@ def include_defined_parameters(keyset):
537549
else:
538550
val = newval
539551

540-
ebtxt.append("%s = %s" % (key, val))
552+
add_key_and_comments(key, val)
553+
541554
printed_keys.append(key)
542555
printed = True
543556
if printed:
544557
ebtxt.append('')
545558

546-
# print easyconfig parameters ordered and in groups specified above
547559
ebtxt = []
548560
printed_keys = []
549-
include_defined_parameters(grouped_keys)
561+
562+
# add header comments
563+
ebtxt.extend(self.comments['header'])
564+
565+
# print easyconfig parameters ordered and in groups specified above
566+
include_defined_parameters(GROUPED_PARAMS)
550567

551568
# print other easyconfig parameters at the end
552-
keys_to_ignore = printed_keys + last_keys
569+
keys_to_ignore = printed_keys + LAST_PARAMS
553570
for key in default_values:
554571
if key not in keys_to_ignore and self[key] != default_values[key]:
555-
ebtxt.append("%s = %s" % (key, quote_py_str(self[key])))
572+
add_key_and_comments(key, quote_py_str(self[key]))
556573
ebtxt.append('')
557574

558-
# print last two parameters
559-
include_defined_parameters([[k] for k in last_keys])
575+
# print last parameters
576+
include_defined_parameters([[k] for k in LAST_PARAMS])
560577

561-
eb_file.write(('\n'.join(ebtxt)).strip()) # strip for newlines at the end
562-
eb_file.close()
578+
write_file(fp, ('\n'.join(ebtxt)).strip()) # strip for newlines at the end
563579

564580
self.enable_templating = orig_enable_templating
565581

easybuild/framework/easyconfig/parser.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
3030
@author: Stijn De Weirdt (Ghent University)
3131
"""
32+
import copy
3233
import os
3334
import re
3435
from vsc.utils import fancylogger
@@ -84,6 +85,9 @@ def __init__(self, filename=None, format_version=None, rawcontent=None):
8485

8586
self.rawcontent = None # the actual unparsed content
8687

88+
# comments in the easyconfig file
89+
self.comments = None
90+
8791
self.get_fn = None # read method and args
8892
self.set_fn = None # write method and args
8993

@@ -99,6 +103,8 @@ def __init__(self, filename=None, format_version=None, rawcontent=None):
99103
else:
100104
raise EasyBuildError("Neither filename nor rawcontent provided to EasyConfigParser")
101105

106+
self._extract_comments()
107+
102108
def process(self, filename=None):
103109
"""Create an instance"""
104110
self._read(filename=filename)
@@ -131,6 +137,52 @@ def _read(self, filename=None):
131137
msg = 'rawcontent is not basestring: type %s, content %s' % (type(self.rawcontent), self.rawcontent)
132138
raise EasyBuildError("Unexpected result for raw content: %s", msg)
133139

140+
def _extract_comments(self):
141+
"""Extract comments from raw content."""
142+
# Keep track of comments and their location (top of easyconfig, key they are intended for, line they are on
143+
# discriminate between header comments (top of easyconfig file), single-line comments (at end of line) and other
144+
# At the moment there is no support for inline comments on lines that don't contain the key value
145+
146+
self.comments = {
147+
'above' : {},
148+
'header' : [],
149+
'inline' : {},
150+
}
151+
152+
rawlines = self.rawcontent.split('\n')
153+
154+
# extract header first
155+
while rawlines[0].startswith('#'):
156+
self.comments['header'].append(rawlines.pop(0))
157+
158+
parsed_ec = None
159+
while rawlines:
160+
rawline = rawlines.pop(0)
161+
if rawline.startswith('#'):
162+
comment = []
163+
# comment could be multi-line
164+
while rawline.startswith('#') or not rawline:
165+
# drop empty lines (that don't even include a #)
166+
if rawline:
167+
comment.append(rawline)
168+
rawline = rawlines.pop(0)
169+
key = rawline.split('=', 1)[0].strip()
170+
self.comments['above'][key] = comment
171+
172+
elif '#' in rawline: # inline comment
173+
if parsed_ec is None:
174+
# obtain parsed easyconfig as a dict, if it wasn't already
175+
# note: this currently trigger a reparse
176+
parsed_ec = self.get_config_dict()
177+
178+
key = rawline.split('=', 1)[0].strip()
179+
comment = rawline.rsplit('#', 1)[1].strip()
180+
181+
# check if hash actually indicated a comment, or if it is part of the value
182+
if key in parsed_ec:
183+
if comment.replace("'", "").replace('"', '') not in str(parsed_ec[key]):
184+
self.comments['inline'][key] = '# ' + comment
185+
134186
def _det_format_version(self):
135187
"""Extract the format version from the raw content"""
136188
if self.format_version is None:
@@ -184,3 +236,7 @@ def get_config_dict(self, validate=True):
184236
if validate:
185237
self._formatter.validate()
186238
return self._formatter.get_config_dict()
239+
240+
def get_comments(self):
241+
"""Return comments, and their location info"""
242+
return copy.deepcopy(self.comments)

test/framework/easyconfig.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1273,6 +1273,53 @@ def test_dump_template(self):
12731273
# reparsing the dumped easyconfig file should work
12741274
ecbis = EasyConfig(testec)
12751275

1276+
def test_dump_comments(self):
1277+
""" Test dump() method for files containing comments """
1278+
rawtxt = '\n'.join([
1279+
"# #",
1280+
"# some header comment",
1281+
"# #",
1282+
"easyblock = 'EB_foo'",
1283+
'',
1284+
"name = 'Foo' # name comment",
1285+
"version = '0.0.1'",
1286+
"versionsuffix = '-test'",
1287+
'',
1288+
"# comment on the homepage",
1289+
"homepage = 'http://foo.com/'",
1290+
'description = "foo description with a # in it" # test',
1291+
'',
1292+
"# toolchain comment",
1293+
'',
1294+
"toolchain = {'version': 'dummy', 'name': 'dummy'}",
1295+
'',
1296+
"sanity_check_paths = {'files': ['files/foo/foobar'], 'dirs':[] }",
1297+
'',
1298+
"foo_extra1 = 'foobar'",
1299+
])
1300+
1301+
handle, testec = tempfile.mkstemp(prefix=self.test_prefix, suffix='.eb')
1302+
os.close(handle)
1303+
1304+
ec = EasyConfig(None, rawtxt=rawtxt)
1305+
ec.dump(testec)
1306+
ectxt = read_file(testec)
1307+
1308+
patterns = [
1309+
r"# #\n# some header comment\n# #",
1310+
r"name = 'Foo' # name comment",
1311+
r"# comment on the homepage\nhomepage = 'http://foo.com/'",
1312+
r'description = "foo description with a # in it" # test',
1313+
r"# toolchain comment\ntoolchain = {'version': 'dummy', 'name': 'dummy'}"
1314+
]
1315+
1316+
for pattern in patterns:
1317+
regex = re.compile(pattern, re.M)
1318+
self.assertTrue(regex.search(ectxt), "Pattern '%s' found in: %s" % (regex.pattern, ectxt))
1319+
1320+
# reparsing the dumped easyconfig file should work
1321+
ecbis = EasyConfig(testec)
1322+
12761323
def test_to_template_str(self):
12771324
""" Test for to_template_str method """
12781325

0 commit comments

Comments
 (0)