Skip to content

Commit 54f9267

Browse files
authored
Expand ${var} in benchcomp variant env (#3090)
The values of environment variables in the benchcomp configuration file can now contain strings of the form '${var}'. Benchcomp will replace these strings with the value of the environment variable 'var'. This is intended to allow users to have several benchcomp variants, each of which differs only in the environment. This fixes #2981.
1 parent f8c30d9 commit 54f9267

File tree

4 files changed

+165
-2
lines changed

4 files changed

+165
-2
lines changed

docs/src/benchcomp-conf.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,26 @@
44
This page lists the different visualizations that are available.
55

66

7+
## Variants
8+
9+
A *variant* is a single invocation of a benchmark suite. Benchcomp runs several
10+
variants, so that their performance can be compared later. A variant consists of
11+
a command-line argument, working directory, and environment. Benchcomp invokes
12+
the command using the operating system environment, updated with the keys and
13+
values in `env`. If any values in `env` contain strings of the form `${var}`,
14+
Benchcomp expands them to the value of the environment variable `$var`.
15+
16+
```yaml
17+
variants:
18+
variant_1:
19+
config:
20+
command_line: echo "Hello, world"
21+
directory: /tmp
22+
env:
23+
PATH: /my/local/directory:${PATH}
24+
```
25+
26+
727
## Built-in visualizations
828
929
The following visualizations are available; these can be added to the `visualize` list of `benchcomp.yaml`.

tools/benchcomp/benchcomp/entry/run.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import logging
1414
import os
1515
import pathlib
16+
import re
1617
import shutil
1718
import subprocess
1819
import typing
@@ -53,9 +54,10 @@ def __post_init__(self):
5354
else:
5455
self.working_copy = pathlib.Path(self.directory)
5556

57+
5658
def __call__(self):
57-
env = dict(os.environ)
58-
env.update(self.env)
59+
update_environment_with = _EnvironmentUpdater()
60+
env = update_environment_with(self.env)
5961

6062
if self.copy_benchmarks_dir:
6163
shutil.copytree(
@@ -128,6 +130,44 @@ def __call__(self):
128130
tmp_symlink.rename(self.out_symlink)
129131

130132

133+
134+
@dataclasses.dataclass
135+
class _EnvironmentUpdater:
136+
"""Update the OS environment with keys and values containing variables
137+
138+
When called, this class returns the operating environment updated with new
139+
keys and values. The values can contain variables of the form '${var_name}'.
140+
The class evaluates those variables using values already in the environment.
141+
"""
142+
143+
os_environment: dict = dataclasses.field(
144+
default_factory=lambda : dict(os.environ))
145+
pattern: re.Pattern = re.compile(r"\$\{(\w+?)\}")
146+
147+
148+
def _evaluate(self, key, value):
149+
"""Evaluate all ${var} in value using self.os_environment"""
150+
old_value = value
151+
152+
for variable in re.findall(self.pattern, value):
153+
if variable not in self.os_environment:
154+
logging.error(
155+
"Couldn't evaluate ${%s} in the value '%s' for environment "
156+
"variable '%s'. Ensure the environment variable $%s is set",
157+
variable, old_value, key, variable)
158+
sys.exit(1)
159+
value = re.sub(
160+
r"\$\{" + variable + "\}", self.os_environment[variable], value)
161+
return value
162+
163+
164+
def __call__(self, new_environment):
165+
ret = dict(self.os_environment)
166+
for key, value in new_environment.items():
167+
ret[key] = self._evaluate(key, value)
168+
return ret
169+
170+
131171
def get_default_out_symlink():
132172
return "latest"
133173

tools/benchcomp/test/test_regression.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,43 @@ def test_return_0_on_fail(self):
662662
result = yaml.safe_load(handle)
663663

664664

665+
def test_env_expansion(self):
666+
"""Ensure that config parser expands '${}' in env key"""
667+
668+
with tempfile.TemporaryDirectory() as tmp:
669+
run_bc = Benchcomp({
670+
"variants": {
671+
"env_set": {
672+
"config": {
673+
"command_line": 'echo "$__BENCHCOMP_ENV_VAR" > out',
674+
"directory": tmp,
675+
"env": {"__BENCHCOMP_ENV_VAR": "foo:${PATH}"}
676+
}
677+
},
678+
},
679+
"run": {
680+
"suites": {
681+
"suite_1": {
682+
"parser": {
683+
# The word 'bin' typically appears in $PATH, so
684+
# check that what was echoed contains 'bin'.
685+
"command": textwrap.dedent("""\
686+
grep bin out && grep '^foo:' out && echo '{
687+
"benchmarks": {},
688+
"metrics": {}
689+
}'
690+
""")
691+
},
692+
"variants": ["env_set"]
693+
}
694+
}
695+
},
696+
"visualize": [],
697+
})
698+
run_bc()
699+
self.assertEqual(run_bc.proc.returncode, 0, msg=run_bc.stderr)
700+
701+
665702
def test_env(self):
666703
"""Ensure that benchcomp reads the 'env' key of variant config"""
667704

tools/benchcomp/test/test_unit.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Copyright Kani Contributors
2+
# SPDX-License-Identifier: Apache-2.0 OR MIT
3+
#
4+
# Benchcomp regression testing suite. This suite uses Python's stdlib unittest
5+
# module, but nevertheless actually runs the binary rather than running unit
6+
# tests.
7+
8+
import unittest
9+
import uuid
10+
11+
import benchcomp.entry.run
12+
13+
14+
15+
class TestEnvironmentUpdater(unittest.TestCase):
16+
def test_environment_construction(self):
17+
"""Test that the default constructor reads the OS environment"""
18+
19+
update_environment = benchcomp.entry.run._EnvironmentUpdater()
20+
environment = update_environment({})
21+
self.assertIn("PATH", environment)
22+
23+
24+
def test_placeholder_construction(self):
25+
"""Test that the placeholder constructor reads the placeholder"""
26+
27+
key, value = [str(uuid.uuid4()) for _ in range(2)]
28+
update_environment = benchcomp.entry.run._EnvironmentUpdater({
29+
key: value,
30+
})
31+
environment = update_environment({})
32+
self.assertIn(key, environment)
33+
self.assertEqual(environment[key], value)
34+
35+
36+
def test_environment_update(self):
37+
"""Test that the environment is updated"""
38+
39+
key, value, update = [str(uuid.uuid4()) for _ in range(3)]
40+
update_environment = benchcomp.entry.run._EnvironmentUpdater({
41+
key: value,
42+
})
43+
environment = update_environment({
44+
key: update
45+
})
46+
self.assertIn(key, environment)
47+
self.assertEqual(environment[key], update)
48+
49+
50+
def test_environment_update_variable(self):
51+
"""Test that the environment is updated"""
52+
53+
old_env = {
54+
"key1": str(uuid.uuid4()),
55+
"key2": str(uuid.uuid4()),
56+
}
57+
58+
actual_update = "${key2}xxx${key1}"
59+
expected_update = f"{old_env['key2']}xxx{old_env['key1']}"
60+
61+
update_environment = benchcomp.entry.run._EnvironmentUpdater(old_env)
62+
environment = update_environment({
63+
"key1": actual_update,
64+
})
65+
self.assertIn("key1", environment)
66+
self.assertEqual(environment["key1"], expected_update)

0 commit comments

Comments
 (0)