Skip to content

Commit 9118199

Browse files
authored
Fix config precedence: command line options now have highest precedence (#544)
1 parent 92f02d7 commit 9118199

File tree

4 files changed

+108
-47
lines changed

4 files changed

+108
-47
lines changed

.github/workflows/test.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,5 +58,16 @@ jobs:
5858
run: |
5959
uv sync --extra dev
6060
61+
- name: Write halmos.toml
62+
shell: bash
63+
run: |
64+
uv run python -m halmos.config \
65+
--storage-layout ${{ matrix.storage-layout }} \
66+
${{ matrix.cache-solver }} \
67+
| tee tests/regression/halmos.toml
68+
6169
- name: Run pytest
62-
run: uv run pytest -v -k "not long and not ffi" --ignore=tests/lib --halmos-options="-st --disable-gc --solver-threads 1 --storage-layout ${{ matrix.storage-layout }} ${{ matrix.cache-solver }} --solver-timeout-assertion 0 ${{ inputs.halmos-options }}" ${{ inputs.pytest-options }}
70+
shell: bash
71+
run: |
72+
uv run pytest -v -k "not long and not ffi" --ignore=tests/lib \
73+
--halmos-options="-st --disable-gc --solver-threads 1 ${{ inputs.halmos-options }}" ${{ inputs.pytest-options }}

src/halmos/config.py

Lines changed: 37 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from dataclasses import MISSING, dataclass, fields
1111
from dataclasses import field as dataclass_field
1212
from enum import Enum, IntEnum
13-
from functools import cached_property
13+
from functools import cached_property, lru_cache
1414
from typing import Any
1515

1616
import toml
@@ -48,14 +48,14 @@ class ConfigSource(IntEnum):
4848
# e.g. halmos.toml
4949
config_file = 2
5050

51-
# from command line
52-
command_line = 3
53-
5451
# contract-level annotation (e.g. @custom:halmos --some-option)
55-
contract_annotation = 4
52+
contract_annotation = 3
5653

5754
# function-level annotation (e.g. @custom:halmos --some-option)
58-
function_annotation = 5
55+
function_annotation = 4
56+
57+
# from command line, highest precedence
58+
command_line = 5
5959

6060

6161
# helper to define config fields
@@ -639,25 +639,33 @@ class Config:
639639

640640
### Methods
641641

642-
def __getattribute__(self, name):
643-
"""Look up values in parent object if they are not set in the current object.
644-
645-
This is because we consider the current object to override its parent.
642+
def __hash__(self):
643+
return id(self)
646644

647-
Because of this, printing a Config object will show a "flattened/resolved" view of the configuration.
645+
# cachable because each layer is immutable
646+
@lru_cache(maxsize=64) # noqa: B019
647+
def __getattribute__(self, name):
648+
"""
649+
Look up values based on precedence, where higher ConfigSource values override lower ones.
648650
"""
651+
# Handle internal attributes normally
652+
if name[0] == "_" or name in (
653+
"value_with_source",
654+
"values",
655+
"values_by_layer",
656+
"formatted_layers",
657+
"resolved_solver_command",
658+
"with_overrides",
659+
):
660+
return object.__getattribute__(self, name)
649661

650-
# look up value in current object
651-
value = object.__getattribute__(self, name)
652-
if value is not None:
662+
# For config fields, use precedence-based lookup
663+
try:
664+
value, _ = self.value_with_source(name)
653665
return value
654-
655-
# look up value in parent object
656-
parent = object.__getattribute__(self, "_parent")
657-
if parent is not None:
658-
return getattr(parent, name)
659-
660-
return value
666+
except AttributeError:
667+
# Fall back to normal attribute lookup for non-config fields
668+
return object.__getattribute__(self, name)
661669

662670
def with_overrides(self, source: ConfigSource, **overrides):
663671
"""Create a new configuration object with some fields overridden.
@@ -673,26 +681,16 @@ def with_overrides(self, source: ConfigSource, **overrides):
673681
sys.exit(2)
674682

675683
def value_with_source(self, name: str) -> tuple[Any, ConfigSource]:
676-
# look up value in current object
677-
value = object.__getattribute__(self, name)
678-
if value is not None:
679-
return (value, self._source)
684+
best_value, best_source = None, ConfigSource.void
680685

681-
# look up value in parent object
682-
parent = self._parent
683-
if parent is not None:
684-
return parent.value_with_source(name)
686+
current = self
687+
while current is not None:
688+
value = object.__getattribute__(current, name)
689+
if value is not None and (current_source := current._source) > best_source:
690+
best_value, best_source = value, current_source
691+
current = current._parent
685692

686-
return (value, self._source)
687-
688-
def values_with_sources(self) -> dict[str, tuple[Any, ConfigSource]]:
689-
# field -> (value, source)
690-
values = {}
691-
for field in fields(self):
692-
if field.metadata.get(internal):
693-
continue
694-
values[field.name] = self.value_with_source(field.name)
695-
return values
693+
return (best_value, best_source)
696694

697695
def values(self):
698696
skip_empty = self._parent is not None

tests/regression/test/StdAssertTest.t.sol

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,15 @@ contract StdAssertPassTest is Test {
4242
assertEq(x, y);
4343
}
4444

45+
// times out after 1min with yices and --cache-solver
46+
/// @custom:halmos --solver z3
4547
function check_assertEq(string memory x, string memory y) public {
4648
vm.assume(keccak256(bytes(x)) == keccak256(bytes(y)));
4749
assertEq(x, y);
4850
}
4951

52+
// times out after 1min with yices and --cache-solver
53+
/// @custom:halmos --solver z3
5054
function check_assertEq(bytes memory x, bytes memory y) public {
5155
vm.assume(keccak256(x) == keccak256(y));
5256
assertEq(x, y);
@@ -512,11 +516,15 @@ contract StdAssertFailLogTest is Test {
512516
assertNotEq(x, y, "");
513517
}
514518

519+
// times out after 1min with yices and --cache-solver
520+
/// @custom:halmos --solver z3
515521
function check_assertNotEq(string memory x, string memory y) public {
516522
vm.assume(!(keccak256(bytes(x)) != keccak256(bytes(y))));
517523
assertNotEq(x, y, "");
518524
}
519525

526+
// times out after 1min with yices and --cache-solver
527+
/// @custom:halmos --solver z3
520528
function check_assertNotEq(bytes memory x, bytes memory y) public {
521529
vm.assume(!(keccak256(x) != keccak256(y)));
522530
assertNotEq(x, y, "");

tests/test_config.py

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,9 @@ def test_count_arg(config, parser):
6767
args = parser.parse_args(["-vvvvvv"])
6868
assert args.verbose == 6
6969

70-
config_from_args = config.with_overrides(source="command-line", **vars(args))
70+
config_from_args = config.with_overrides(
71+
source=ConfigSource.command_line, **vars(args)
72+
)
7173
assert config_from_args.verbose == 6
7274

7375

@@ -78,7 +80,7 @@ def test_choice_arg(config, parser):
7880

7981
# valid choice works
8082
args = parser.parse_args(["--storage-layout", "generic"])
81-
overrides = config.with_overrides(source="command-line", **vars(args))
83+
overrides = config.with_overrides(source=ConfigSource.command_line, **vars(args))
8284
assert overrides.storage_layout == "generic"
8385

8486

@@ -380,8 +382,50 @@ def test_solver_resolution_precedence(config):
380382
# the solver command comes from the contract annotation
381383
assert contract_config.solver_command == "path/to/bitwuzla --produce-models"
382384

383-
# the resolved solver command is derived from the contract annotation
384-
assert contract_config.resolved_solver_command == [
385-
"path/to/bitwuzla",
386-
"--produce-models",
387-
]
385+
# the resolved solver command is derived from the solver option (command line)
386+
# because command line has higher precedence than contract annotation
387+
assert "cvc5" in " ".join(contract_config.resolved_solver_command)
388+
389+
390+
def test_command_line_precedence_over_annotations(config):
391+
"""Test that command line options have highest precedence, overriding annotations."""
392+
393+
# Start with default config
394+
assert config.solver == "yices"
395+
396+
# Apply config file override
397+
config_file_config = config.with_overrides(ConfigSource.config_file, solver="z3")
398+
assert config_file_config.solver == "z3"
399+
assert config_file_config.value_with_source("solver") == (
400+
"z3",
401+
ConfigSource.config_file,
402+
)
403+
404+
# Apply command line override (should have highest precedence)
405+
cli_config = config_file_config.with_overrides(
406+
ConfigSource.command_line, solver="cvc5"
407+
)
408+
assert cli_config.solver == "cvc5"
409+
assert cli_config.value_with_source("solver") == ("cvc5", ConfigSource.command_line)
410+
411+
# Apply contract annotation override (should NOT override command line)
412+
contract_config = cli_config.with_overrides(
413+
ConfigSource.contract_annotation, solver="bitwuzla"
414+
)
415+
# Command line should still win
416+
assert contract_config.solver == "cvc5"
417+
assert contract_config.value_with_source("solver") == (
418+
"cvc5",
419+
ConfigSource.command_line,
420+
)
421+
422+
# Apply function annotation override (should NOT override command line)
423+
function_config = contract_config.with_overrides(
424+
ConfigSource.function_annotation, solver="yices"
425+
)
426+
# Command line should still win
427+
assert function_config.solver == "cvc5"
428+
assert function_config.value_with_source("solver") == (
429+
"cvc5",
430+
ConfigSource.command_line,
431+
)

0 commit comments

Comments
 (0)