Skip to content

Commit 7a77c9b

Browse files
List variables can now extend config items (#753)
* List variables can now extend config items - List variables can now be used to extend config items. - Variable lists cannot be extended in this way. * Update format.rst * Update expressions.py whitespace cleanup * Update expressions.py whitespace fix --------- Co-authored-by: tygoetsch <tgoetsch@lanl.gov>
1 parent 0b02e24 commit 7a77c9b

File tree

7 files changed

+244
-60
lines changed

7 files changed

+244
-60
lines changed

docs/tests/format.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,25 @@ automatically interprets that as a list of that single value.
174174
- {bar: 2}
175175
baz: {buz: "hello"}
176176
177+
Extending Config Lists
178+
^^^^^^^^^^^^^^^^^^^^^^
179+
180+
Items in the config that can take a list can be extended by a list variable.
181+
182+
.. code:: yaml
183+
184+
mytest:
185+
variables:
186+
extra_modules:
187+
- intel
188+
- intel-mkl
189+
190+
build:
191+
modules:
192+
- openmpi
193+
# All the values from the 'extra_modules' variable will be added to the list.
194+
- '{{ extra_modules.* }}'
195+
177196
Hidden Tests
178197
------------
179198

docs/tests/values.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ identically to Python3 (with one noted exception). This includes:
6767
- Power operations, though Pavilion uses ``^`` to denote these. ``a ^ 3``
6868
- Logical operations ``a and b or not False``.
6969
- Parenthetical expressions ``a * (b + 1)``
70+
- Concatenation ``"hello " .. "world"`` and ``[1, 2 ,3] .. [4, 5, 6]``
7071

7172
List Operations
7273
```````````````

lib/pavilion/parsers/expressions.py

Lines changed: 58 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@
3535
compare_expr: add_expr ((EQ | NOT_EQ | LT | GT | LT_EQ | GT_EQ ) add_expr)*
3636
add_expr: mult_expr ((PLUS | MINUS) mult_expr)*
3737
mult_expr: pow_expr ((TIMES | DIVIDE | INT_DIV | MODULUS) pow_expr)*
38-
pow_expr: primary ("^" primary)?
38+
pow_expr: conc_expr ("^" conc_expr)?
39+
conc_expr: primary (CONCAT primary)*
3940
primary: literal
4041
| var_ref
4142
| negative
@@ -78,6 +79,9 @@
7879
DIVIDE: "/"
7980
INT_DIV: "//"
8081
MODULUS: "%"
82+
// The ignored whitespace below mucks with this, which requires us to include the whitespace in the
83+
// token definition.
84+
CONCAT: / *\.\./
8185
AND: /and(?![a-zA-Z_])/
8286
OR: /or(?![a-zA-Z_])/
8387
NOT.2: /not(?![a-zA-Z_])/
@@ -139,38 +143,38 @@ class BaseExprTransformer(PavTransformer):
139143

140144
def _apply_op(self, op_func: Callable[[Any, Any], Any],
141145
arg1: lark.Token, arg2: lark.Token, allow_strings=True):
142-
""""""
143-
144-
# Verify that the arg value types are something numeric, or that it's a
145-
# string and strings are allowed.
146-
for arg in arg1, arg2:
147-
if isinstance(arg.value, list):
148-
for val in arg.value:
149-
if (isinstance(val, str) and not allow_strings and
150-
not isinstance(val, self.NUM_TYPES)):
151-
raise ParserValueError(
152-
token=arg,
153-
message="Non-numeric value '{}' in list in math "
154-
"operation.".format(val))
155-
else:
156-
if (isinstance(arg.value, str) and not allow_strings and
157-
not isinstance(arg.value, self.NUM_TYPES)):
146+
"""Apply the given op_func to the given arguments. If strings are not allowed, then
147+
the values are converted to numeric types if possible."""
148+
149+
if not allow_strings:
150+
# Shouldn't throw exceptions or introduce invalid types.
151+
val1 = auto_type_convert(arg1.value)
152+
val2 = auto_type_convert(arg2.value)
153+
for arg, val in (arg1, val1), (arg2, val2):
154+
if isinstance(val, str):
158155
raise ParserValueError(
159-
token=arg1,
160-
message="Non-numeric value '{}' in math operation."
161-
.format(arg.value))
156+
arg,
157+
f"Math operation given string '{val}', but strings aren't valid "
158+
"operands")
159+
elif isinstance(val, list):
160+
for subval in val:
161+
if isinstance(subval, str):
162+
raise ParserValueError(
163+
arg,
164+
f"Math operation given string '{subval}', but strings aren't valid "
165+
"operands")
166+
else:
167+
val1 = arg1.value
168+
val2 = arg2.value
162169

163-
if (isinstance(arg1.value, list) and isinstance(arg2.value, list)
164-
and len(arg1.value) != len(arg2.value)):
170+
if (isinstance(val1, list) and isinstance(val2, list)
171+
and len(val1) != len(val2)):
165172
raise ParserValueError(
166173
token=arg2,
167174
message="List operations must be between two equal length "
168175
"lists. Arg1 had {} values, arg2 had {}."
169176
.format(len(arg1.value), len(arg2.value)))
170177

171-
val1 = arg1.value
172-
val2 = arg2.value
173-
174178
if isinstance(val1, list) and not isinstance(val2, list):
175179
return [op_func(val1_part, val2) for val1_part in val1]
176180
elif not isinstance(val1, list) and isinstance(val2, list):
@@ -369,6 +373,35 @@ def pow_expr(self, items) -> lark.Token:
369373
else:
370374
return items[0]
371375

376+
def conc_expr(self, items) -> lark.Token:
377+
"""Concatenate strings or lists. The '..' operator isn't captured."""
378+
379+
if len(items) == 1:
380+
return items[0]
381+
382+
def _concat(val1, val2):
383+
if isinstance(val1, list):
384+
if isinstance(val2, list):
385+
return val1 + val2
386+
else:
387+
return [item + str(val2) for item in val1]
388+
else:
389+
if isinstance(val2, list):
390+
return [str(val1) + item for item in val2]
391+
else:
392+
return str(val1) + str(val2)
393+
394+
base = items[0].value
395+
for item in items[1:]:
396+
if item.type == 'CONCAT':
397+
continue
398+
399+
val = item.value
400+
401+
base = _concat(base, val)
402+
403+
return self._merge_tokens(items, base)
404+
372405
def primary(self, items) -> lark.Token:
373406
"""Simply pass the value up to the next layer.
374407
:param list[Token] items: Will only be a single item.

lib/pavilion/parsers/strings.py

Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import lark
1313
from .common import PavTransformer
1414
from ..errors import ParserValueError
15+
from ..utils import auto_type_convert
1516
from .expressions import get_expr_parser, ExprTransformer, VarRefVisitor
1617

1718
STRING_GRAMMAR = r'''
@@ -152,7 +153,31 @@ def start(self, items) -> str:
152153
if len(items) > 1:
153154
parts.append('\n')
154155

155-
return ''.join(parts)
156+
# If everything is a string, join the bits and return them.
157+
is_str = lambda v: isinstance(v, str)
158+
if all(map(is_str, parts)):
159+
return ''.join(parts)
160+
161+
# Check if all the parts are whitespace or a (single) list.
162+
found_list = None
163+
for part in parts:
164+
if isinstance(part, list):
165+
if found_list is None:
166+
found_list = part
167+
else:
168+
raise ParserValueError(
169+
token=self._merge_tokens(items, parts),
170+
message="Value contained multiple expressions that resolved to lists.")
171+
elif not (is_str(part) and part.isspace()):
172+
raise ParserValueError(
173+
token=self._merge_tokens(items, parts),
174+
message="Value resolved to a list, but also contained none-whitespace.")
175+
if not found_list:
176+
raise ParserValueError(
177+
token=self._merge_tokens(items, parts),
178+
message="Value resolved to an invalid type (this should never happen).")
179+
180+
return found_list
156181

157182
def string(self, items) -> lark.Token:
158183
"""Strings are merged into a single token whose value is all
@@ -357,29 +382,48 @@ def _resolve_expr(self,
357382
err.pos_in_stream += expr.start_pos
358383
raise
359384

360-
if not isinstance(value, (int, float, bool, str)):
385+
format_spec = expr.value['format_spec']
386+
if format_spec is not None:
387+
spec = format_spec[1:]
388+
def _format(val):
389+
try:
390+
return f'{val:{spec}}'
391+
except ValueError as err:
392+
try:
393+
val = auto_type_convert(val)
394+
return f'{val:{spec}}'
395+
except ValueError as err:
396+
raise ParserValueError(
397+
expr, f"Invalid format_spec '{spec}' for value '{val}': {err}")
398+
else:
399+
_format = str
400+
401+
if isinstance(value, list):
402+
formatted = []
403+
for idx, item in enumerate(value):
404+
if not isinstance(item, (int, float, bool, str)):
405+
type_name = type(value).__name__
406+
raise ParserValueError(
407+
expr,
408+
"Pavilion expression resolved to a list with a bad item. Expression "
409+
"lists can only contain basic data types (int, float, str, bool), but "
410+
"we got type {} in position {} with value: \n{}"
411+
.format(type_name, idx, item))
412+
413+
formatted.append(_format(item))
414+
415+
return formatted
416+
417+
elif not isinstance(value, (int, float, bool, str)):
361418
type_name = type(value).__name__
362419
raise ParserValueError(
363420
expr,
364421
"Pavilion expressions must resolve to a string, int, float, "
365-
"or boolean. Instead, we got {} '{}'"
422+
"or boolean (or a list of such values). Instead, we got {} '{}'"
366423
.format('an' if type_name[0] in 'aeiou' else 'a', type_name))
367424

368-
format_spec = expr.value['format_spec']
369-
370-
if format_spec is not None:
371-
try:
372-
value = '{value:{format_spec}}'.format(
373-
format_spec=format_spec[1:],
374-
value=value)
375-
except ValueError as err:
376-
raise ParserValueError(
377-
expr,
378-
"Invalid format_spec '{}': {}".format(format_spec, err))
379425
else:
380-
value = str(value)
381-
382-
return value
426+
return _format(value)
383427

384428
@staticmethod
385429
def _displace_token(base: lark.Token, inner: lark.Token):

lib/pavilion/resolve.py

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,22 @@ def test_config(config, var_man):
3434

3535
for section in config:
3636
try:
37-
resolved_dict[section] = section_values(
37+
section_val = config[section]
38+
39+
resolved_val = section_values(
3840
component=config[section],
3941
var_man=var_man,
4042
allow_deferred=section not in NO_DEFERRED_ALLOWED,
4143
key_parts=(section,),
4244
)
45+
46+
if isinstance(section_val, str) and isinstance(resolved_val, list):
47+
raise TestConfigError(
48+
"Section '{}' was set to '{}' which resolved to list '{}'. This key does "
49+
"not accept lists.".format(section, section_val, resolved_val))
50+
51+
resolved_dict[section] = resolved_val
52+
4353
except (StringParserError, ParserValueError) as err:
4454
raise TestConfigError("Error parsing '{}' section".format(section), err)
4555

@@ -144,26 +154,39 @@ def section_values(component: Union[Dict, List, str],
144154

145155
if isinstance(component, dict):
146156
resolved_dict = type(component)()
147-
for key in component.keys():
148-
resolved_dict[key] = section_values(
149-
component[key],
157+
for key, val in component.items():
158+
resolved_val = section_values(
159+
val,
150160
var_man,
151161
allow_deferred=allow_deferred,
152162
deferred_only=deferred_only,
153163
key_parts=key_parts + (key,))
164+
if isinstance(val, str) and isinstance(resolved_val, list):
165+
# We probably got back a list, which is only valid when dealing with a list
166+
full_key = '.'.join(key_parts + (key,))
167+
raise TestConfigError(
168+
"Key '{}' was set to '{}' which resolved to list '{}'. This key does not "
169+
"accept lists.".format(full_key, val, resolved_val))
170+
171+
resolved_dict[key] = resolved_val
154172

155173
return resolved_dict
156174

157175
elif isinstance(component, list):
158176
resolved_list = type(component)()
159-
for i in range(len(component)):
160-
resolved_list.append(
161-
section_values(
162-
component[i], var_man,
177+
for idx, val in enumerate(component):
178+
resolved_val = section_values(
179+
val, var_man,
163180
allow_deferred=allow_deferred,
164181
deferred_only=deferred_only,
165-
key_parts=key_parts + (i,)
166-
))
182+
key_parts=key_parts + (idx,)
183+
)
184+
# String resolution converted a string to a list - extend this list with those items.
185+
if isinstance(resolved_val, list) and isinstance(val, str):
186+
resolved_list.extend(resolved_val)
187+
else:
188+
resolved_list.append(resolved_val)
189+
167190
return resolved_list
168191

169192
elif isinstance(component, str):

0 commit comments

Comments
 (0)