1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
|
# Copyright 2013-2018 Lawrence Livermore National Security, LLC and other
# Spack Project Developers. See the top-level COPYRIGHT file for details.
#
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
"""Here we consolidate the logic for creating an abstract description
of the information that module systems need.
This information maps **a single spec** to:
* a unique module filename
* the module file content
and is divided among four classes:
* a configuration class that provides a convenient interface to query
details about the configuration for the spec under consideration.
* a layout class that provides the information associated with module
file names and directories
* a context class that provides the dictionary used by the template engine
to generate the module file
* a writer that collects and uses the information above to either write
or remove the module file
Each of the four classes needs to be sub-classed when implementing a new
module type.
"""
import copy
import datetime
import inspect
import os.path
import re
import six
import llnl.util.filesystem
import llnl.util.tty as tty
import spack.paths
import spack.build_environment as build_environment
import spack.util.environment
import spack.tengine as tengine
import spack.util.path
import spack.util.environment
import spack.error
#: config section for this file
configuration = spack.config.get('modules')
#: Root folders where the various module files should be written
roots = spack.config.get('config:module_roots', {})
#: Inspections that needs to be done on spec prefixes
prefix_inspections = spack.config.get('modules:prefix_inspections', {})
#: Valid tokens for naming scheme and env variable names
_valid_tokens = (
'PACKAGE',
'VERSION',
'COMPILER',
'COMPILERNAME',
'COMPILERVER',
'ARCHITECTURE'
)
def _check_tokens_are_valid(format_string, message):
"""Checks that the tokens used in the format string are valid in
the context of module file and environment variable naming.
Args:
format_string (str): string containing the format to be checked. This
is supposed to be a 'template' for ``Spec.format``
message (str): first sentence of the error message in case invalid
tokens are found
"""
named_tokens = re.findall(r'\${(\w*)}', format_string)
invalid_tokens = [x for x in named_tokens if x not in _valid_tokens]
if invalid_tokens:
msg = message
msg += ' [{0}]. '.format(', '.join(invalid_tokens))
msg += 'Did you check your "modules.yaml" configuration?'
raise RuntimeError(msg)
def update_dictionary_extending_lists(target, update):
"""Updates a dictionary, but extends lists instead of overriding them.
Args:
target: dictionary to be updated
update: update to be applied
"""
for key in update:
value = target.get(key, None)
if isinstance(value, list):
target[key].extend(update[key])
elif isinstance(value, dict):
update_dictionary_extending_lists(target[key], update[key])
else:
target[key] = update[key]
def dependencies(spec, request='all'):
"""Returns the list of dependent specs for a given spec, according to the
request passed as parameter.
Args:
spec: spec to be analyzed
request: either 'none', 'direct' or 'all'
Returns:
list of dependencies
The return list will be empty if request is 'none', will contain
the direct dependencies if request is 'direct', or the entire DAG
if request is 'all'.
"""
if request not in ('none', 'direct', 'all'):
message = "Wrong value for argument 'request' : "
message += "should be one of ('none', 'direct', 'all')"
raise tty.error(message + " [current value is '%s']" % request)
if request == 'none':
return []
if request == 'direct':
return spec.dependencies(deptype=('link', 'run'))
# FIXME : during module file creation nodes seem to be visited multiple
# FIXME : times even if cover='nodes' is given. This work around permits
# FIXME : to get a unique list of spec anyhow. Do we miss a merge
# FIXME : step among nodes that refer to the same package?
seen = set()
seen_add = seen.add
deps = sorted(
spec.traverse(order='post',
cover='nodes',
deptype=('link', 'run'),
root=False),
reverse=True)
return [d for d in deps if not (d in seen or seen_add(d))]
def merge_config_rules(configuration, spec):
"""Parses the module specific part of a configuration and returns a
dictionary containing the actions to be performed on the spec passed as
an argument.
Args:
configuration: module specific configuration (e.g. entries under
the top-level 'tcl' key)
spec: spec for which we need to generate a module file
Returns:
dict: actions to be taken on the spec passed as an argument
"""
# Get the top-level configuration for the module type we are using
module_specific_configuration = copy.deepcopy(configuration)
# Construct a dictionary with the actions we need to perform on the spec
# passed as a parameter
# The keyword 'all' is always evaluated first, all the others are
# evaluated in order of appearance in the module file
spec_configuration = module_specific_configuration.pop('all', {})
for constraint, action in module_specific_configuration.items():
override = False
if constraint.endswith(':'):
constraint = constraint.strip(':')
override = True
if spec.satisfies(constraint, strict=True):
if override:
spec_configuration = {}
update_dictionary_extending_lists(spec_configuration, action)
# Transform keywords for dependencies or prerequisites into a list of spec
# Which modulefiles we want to autoload
autoload_strategy = spec_configuration.get('autoload', 'none')
spec_configuration['autoload'] = dependencies(spec, autoload_strategy)
# Which instead we want to mark as prerequisites
prerequisite_strategy = spec_configuration.get('prerequisites', 'none')
spec_configuration['prerequisites'] = dependencies(
spec, prerequisite_strategy)
# Attach options that are spec-independent to the spec-specific
# configuration
# Hash length in module files
hash_length = module_specific_configuration.get('hash_length', 7)
spec_configuration['hash_length'] = hash_length
verbose = module_specific_configuration.get('verbose', False)
spec_configuration['verbose'] = verbose
return spec_configuration
def root_path(name):
"""Returns the root folder for module file installation.
Args:
name: name of the module system t be used (e.g. 'tcl')
Returns:
root folder for module file installation
"""
path = roots.get(name, os.path.join(spack.paths.share_path, name))
return spack.util.path.canonicalize_path(path)
class BaseConfiguration(object):
"""Manipulates the information needed to generate a module file to make
querying easier. It needs to be sub-classed for specific module types.
"""
def __init__(self, spec):
# Module where type(self) is defined
self.module = inspect.getmodule(self)
# Spec for which we want to generate a module file
self.spec = spec
# Dictionary of configuration options that should be applied
# to the spec
self.conf = merge_config_rules(self.module.configuration, self.spec)
@property
def naming_scheme(self):
"""Naming scheme suitable for non-hierarchical layouts"""
scheme = self.module.configuration.get(
'naming_scheme',
'${PACKAGE}-${VERSION}-${COMPILERNAME}-${COMPILERVER}'
)
# Ensure the named tokens we are expanding are allowed, see
# issue #2884 for reference
msg = 'some tokens cannot be part of the module naming scheme'
_check_tokens_are_valid(scheme, message=msg)
return scheme
@property
def template(self):
"""Returns the name of the template to use for the module file
or None if not specified in the configuration.
"""
return self.conf.get('template', None)
@property
def env(self):
"""List of environment modifications that should be done in the
module.
"""
env_mods = spack.util.environment.EnvironmentModifications()
actions = self.conf.get('environment', {})
def process_arglist(arglist):
if method == 'unset':
for x in arglist:
yield (x,)
else:
for x in six.iteritems(arglist):
yield x
for method, arglist in actions.items():
for args in process_arglist(arglist):
getattr(env_mods, method)(*args)
return env_mods
@property
def suffixes(self):
"""List of suffixes that should be appended to the module
file name.
"""
suffixes = []
for constraint, suffix in self.conf.get('suffixes', {}).items():
if constraint in self.spec:
suffixes.append(suffix)
if self.hash:
suffixes.append(self.hash)
return suffixes
@property
def hash(self):
"""Hash tag for the module or None"""
hash_length = self.conf.get('hash_length', 7)
if hash_length != 0:
return self.spec.dag_hash(length=hash_length)
return None
@property
def blacklisted(self):
"""Returns True if the module has been blacklisted,
False otherwise.
"""
# A few variables for convenience of writing the method
spec = self.spec
conf = self.module.configuration
# Compute the list of whitelist rules that match
wlrules = conf.get('whitelist', [])
whitelist_matches = [x for x in wlrules if spec.satisfies(x)]
# Compute the list of blacklist rules that match
blrules = conf.get('blacklist', [])
blacklist_matches = [x for x in blrules if spec.satisfies(x)]
# Should I blacklist the module because it's implicit?
blacklist_implicits = conf.get('blacklist_implicits')
installed_implicitly = not spec._installed_explicitly()
blacklisted_as_implicit = blacklist_implicits and installed_implicitly
def debug_info(line_header, match_list):
if match_list:
msg = '\t{0} : {1}'.format(line_header, spec.cshort_spec)
tty.debug(msg)
for rule in match_list:
tty.debug('\t\tmatches rule: {0}'.format(rule))
debug_info('WHITELIST', whitelist_matches)
debug_info('BLACKLIST', blacklist_matches)
if blacklisted_as_implicit:
msg = '\tBLACKLISTED_AS_IMPLICIT : {0}'.format(spec.cshort_spec)
tty.debug(msg)
is_blacklisted = blacklist_matches or blacklisted_as_implicit
if not whitelist_matches and is_blacklisted:
return True
return False
@property
def context(self):
return self.conf.get('context', {})
@property
def specs_to_load(self):
"""List of specs that should be loaded in the module file."""
return self._create_list_for('autoload')
@property
def literals_to_load(self):
"""List of literal modules to be loaded."""
return self.conf.get('load', [])
@property
def specs_to_prereq(self):
"""List of specs that should be prerequisite of the module file."""
return self._create_list_for('prerequisites')
@property
def environment_blacklist(self):
"""List of variables that should be left unmodified."""
return self.conf.get('filter', {}).get('environment_blacklist', {})
def _create_list_for(self, what):
whitelist = []
for item in self.conf[what]:
conf = type(self)(item)
if not conf.blacklisted:
whitelist.append(item)
return whitelist
@property
def verbose(self):
"""Returns True if the module file needs to be verbose, False
otherwise
"""
return self.conf.get('verbose')
class BaseFileLayout(object):
"""Provides information on the layout of module files. Needs to be
sub-classed for specific module types.
"""
#: This needs to be redefined
extension = None
def __init__(self, configuration):
self.conf = configuration
@property
def spec(self):
"""Spec under consideration"""
return self.conf.spec
@classmethod
def dirname(cls):
"""Root folder for module files of this type."""
module_system = str(inspect.getmodule(cls).__name__).split('.')[-1]
return root_path(module_system)
@property
def use_name(self):
"""Returns the 'use' name of the module i.e. the name you have to type
to console to use it. This implementation fits the needs of most
non-hierarchical layouts.
"""
name = self.spec.format(self.conf.naming_scheme)
# Not everybody is working on linux...
parts = name.split('/')
name = os.path.join(*parts)
# Add optional suffixes based on constraints
path_elements = [name] + self.conf.suffixes
return '-'.join(path_elements)
@property
def filename(self):
"""Name of the module file for the current spec."""
# Just the name of the file
filename = self.use_name
if self.extension:
filename = '{0}.{1}'.format(self.use_name, self.extension)
# Architecture sub-folder
arch_folder = str(self.spec.architecture)
# Return the absolute path
return os.path.join(self.dirname(), arch_folder, filename)
class BaseContext(tengine.Context):
"""Provides the base context needed for template rendering.
This class needs to be sub-classed for specific module types. The
following attributes need to be implemented:
- fields
"""
def __init__(self, configuration):
self.conf = configuration
@tengine.context_property
def spec(self):
return self.conf.spec
@tengine.context_property
def timestamp(self):
return datetime.datetime.now()
@tengine.context_property
def category(self):
return getattr(self.spec, 'category', 'spack')
@tengine.context_property
def short_description(self):
# If we have a valid docstring return the first paragraph.
docstring = type(self.spec.package).__doc__
if docstring:
value = docstring.split('\n\n')[0]
# Transform tabs and friends into spaces
value = re.sub(r'\s+', ' ', value)
# Turn double quotes into single quotes (double quotes are needed
# to start and end strings)
value = re.sub(r'"', "'", value)
return value
# Otherwise the short description is just the package + version
return self.spec.format("$_ $@")
@tengine.context_property
def long_description(self):
# long description is the docstring with reduced whitespace.
if self.spec.package.__doc__:
return re.sub(r'\s+', ' ', self.spec.package.__doc__)
return None
@tengine.context_property
def configure_options(self):
pkg = self.spec.package
# If the spec is external Spack doesn't know its configure options
if self.spec.external:
msg = 'unknown, software installed outside of Spack'
return msg
# This is quite simple right now, but contains information on how
# to call different build system classes.
for attr in ('configure_args', 'cmake_args'):
try:
configure_args = getattr(pkg, attr)()
return ' '.join(configure_args)
except (AttributeError, IOError, KeyError):
# The method doesn't exist in the current spec,
# or it's not usable
pass
# Returning a false-like value makes the default templates skip
# the configure option section
return None
@tengine.context_property
def environment_modifications(self):
"""List of environment modifications to be processed."""
# Modifications guessed inspecting the spec prefix
env = spack.util.environment.inspect_path(
self.spec.prefix,
prefix_inspections,
exclude=spack.util.environment.is_system_path
)
# Modifications that are coded at package level
_ = spack.util.environment.EnvironmentModifications()
# TODO : the code down below is quite similar to
# TODO : build_environment.setup_package and needs to be factored out
# TODO : to a single place
# Let the extendee/dependency modify their extensions/dependencies
# before asking for package-specific modifications
for item in dependencies(self.spec, 'all'):
package = self.spec[item.name].package
build_environment.set_module_variables_for_package(package)
package.setup_dependent_package(
self.spec.package.module, self.spec
)
package.setup_dependent_environment(_, env, self.spec)
# Package specific modifications
build_environment.set_module_variables_for_package(self.spec.package)
self.spec.package.setup_environment(_, env)
# Modifications required from modules.yaml
env.extend(self.conf.env)
# List of variables that are blacklisted in modules.yaml
blacklist = self.conf.environment_blacklist
# We may have tokens to substitute in environment commands
# Prepare a suitable transformation dictionary for the names
# of the environment variables. This means turn the valid
# tokens uppercase.
transform = {}
for token in _valid_tokens:
transform[token] = lambda spec, string: str.upper(string)
for x in env:
# Ensure all the tokens are valid in this context
msg = 'some tokens cannot be expanded in an environment variable name' # noqa: E501
_check_tokens_are_valid(x.name, message=msg)
# Transform them
x.name = self.spec.format(x.name, transform=transform)
try:
# Not every command has a value
x.value = self.spec.format(x.value)
except AttributeError:
pass
x.name = str(x.name).replace('-', '_')
return [(type(x).__name__, x) for x in env if x.name not in blacklist]
@tengine.context_property
def autoload(self):
"""List of modules that needs to be loaded automatically."""
# From 'autoload' configuration option
specs = self._create_module_list_of('specs_to_load')
# From 'load' configuration option
literals = self.conf.literals_to_load
return specs + literals
def _create_module_list_of(self, what):
m = self.conf.module
return [m.make_layout(x).use_name
for x in getattr(self.conf, what)]
@tengine.context_property
def verbose(self):
"""Verbosity level."""
return self.conf.verbose
class BaseModuleFileWriter(object):
def __init__(self, spec):
self.spec = spec
# This class is meant to be derived. Get the module of the
# actual writer.
self.module = inspect.getmodule(self)
m = self.module
# Create the triplet of configuration/layout/context
self.conf = m.make_configuration(spec)
self.layout = m.make_layout(spec)
self.context = m.make_context(spec)
# Check if a default template has been defined,
# throw if not found
try:
self.default_template
except AttributeError:
msg = '\'{0}\' object has no attribute \'default_template\'\n'
msg += 'Did you forget to define it in the class?'
name = type(self).__name__
raise DefaultTemplateNotDefined(msg.format(name))
def _get_template(self):
"""Gets the template that will be rendered for this spec."""
# Get templates and put them in the order of importance:
# 1. template specified in "modules.yaml"
# 2. template specified in a package directly
# 3. default template (must be defined, check in __init__)
module_system_name = str(self.module.__name__).split('.')[-1]
package_attribute = '{0}_template'.format(module_system_name)
choices = [
self.conf.template,
getattr(self.spec.package, package_attribute, None),
self.default_template # This is always defined at this point
]
# Filter out false-ish values
choices = list(filter(lambda x: bool(x), choices))
# ... and return the first match
return choices.pop(0)
def write(self, overwrite=False):
"""Writes the module file.
Args:
overwrite (bool): if True it is fine to overwrite an already
existing file. If False the operation is skipped an we print
a warning to the user.
"""
# Return immediately if the module is blacklisted
if self.conf.blacklisted:
msg = '\tNOT WRITING: {0} [BLACKLISTED]'
tty.debug(msg.format(self.spec.cshort_spec))
return
# Print a warning in case I am accidentally overwriting
# a module file that is already there (name clash)
if not overwrite and os.path.exists(self.layout.filename):
message = 'Module file already exists : skipping creation\n'
message += 'file : {0.filename}\n'
message += 'spec : {0.spec}'
tty.warn(message.format(self.layout))
return
# If we are here it means it's ok to write the module file
msg = '\tWRITE: {0} [{1}]'
tty.debug(msg.format(self.spec.cshort_spec, self.layout.filename))
# If the directory where the module should reside does not exist
# create it
module_dir = os.path.dirname(self.layout.filename)
if not os.path.exists(module_dir):
llnl.util.filesystem.mkdirp(module_dir)
# Get the template for the module
template_name = self._get_template()
try:
env = tengine.make_environment()
template = env.get_template(template_name)
except tengine.TemplateNotFound:
# If the template was not found raise an exception with a little
# more information
msg = 'template \'{0}\' was not found for \'{1}\''
name = type(self).__name__
msg = msg.format(template_name, name)
raise ModulesTemplateNotFoundError(msg)
# Construct the context following the usual hierarchy of updates:
# 1. start with the default context from the module writer class
# 2. update with package specific context
# 3. update with 'modules.yaml' specific context
context = self.context.to_dict()
# Attribute from package
module_name = str(self.module.__name__).split('.')[-1]
attr_name = '{0}_context'.format(module_name)
pkg_update = getattr(self.spec.package, attr_name, {})
context.update(pkg_update)
# Context key in modules.yaml
conf_update = self.conf.context
context.update(conf_update)
# Render the template
text = template.render(context)
# Write it to file
with open(self.layout.filename, 'w') as f:
f.write(text)
def remove(self):
"""Deletes the module file."""
mod_file = self.layout.filename
if os.path.exists(mod_file):
try:
os.remove(mod_file) # Remove the module file
os.removedirs(
os.path.dirname(mod_file)
) # Remove all the empty directories from the leaf up
except OSError:
# removedirs throws OSError on first non-empty directory found
pass
class ModulesError(spack.error.SpackError):
"""Base error for modules."""
class DefaultTemplateNotDefined(AttributeError, ModulesError):
"""Raised if the attribute 'default_template' has not been specified
in the derived classes.
"""
class ModulesTemplateNotFoundError(ModulesError, RuntimeError):
"""Raised if the template for a module file was not found."""
|