Skip to content
7 changes: 7 additions & 0 deletions config.toml.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
[devices]
metadata_dev = '/dev/vda'
data_dev = '/dev/vdb'
disable_by_id_check = true
Expand All @@ -8,6 +9,12 @@ disable_by_id_check = true
#
# cache_dev = '/dev/vdc'

[filter]
# Default tags filter
#
tags = '!experimental'

[run]
# Optional cache policy name to override the default in dm-cache tests.
# If specified, all test devices use this policy instead of the default smq policy.
#
Expand Down
2 changes: 1 addition & 1 deletion dmtest
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#! /usr/bin/sh

export PYTHONPATH=$PYTHONPATH:./src
python3 -m dmtest $@
python3 -m dmtest "$@"
50 changes: 50 additions & 0 deletions src/dmtest/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
import dmtest.thin_migrate.register as thin_migrate_register
import dmtest.vdo.register as vdo_register
import dmtest.dependency_tracker as dep
import dmtest.config as config
import dmtest.test_filter as filter
import dmtest.tag_expression as tag_expr
from dmtest.utils import get_dmesg_log
import io
import itertools
Expand Down Expand Up @@ -168,6 +170,10 @@ def cmd_list(tests: test_register.TestRegister, args, results: db.TestResults):

for p in paths:
print(f"{formatter.tree_line(p)}", end=" ")
if args.show_tags:
tags = tests.get_tags(p)
tag_str = f"[{', '.join(sorted(tags))}]" if tags else ""
print(f"{tag_str.ljust(30)}", end=" ")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not a big deal, probably but, I'm concerned about what this formatting will look like. tree_line() justifies everything so the test name will take up 50 columns. You're adding another 30 columns, so the results will necessarily show up past column 80 here, which is awkward if you're using an 80-character-wide terminal (like I do).
Another concern is that if there are three or four tags, they may take more than 30 columns to print.
Perhaps you could steal some columns from tree_line? Perhaps you could put the tags after the results columns? Perhaps you could deliberately add a second line for the tags of each test?

(Also tags here is guaranteed to be a frozenset I think, you shouldn't need the if/else.)

result = average_results(results.get_test_results(p, result_set, args.run_nr))
if result is None:
print("-")
Expand Down Expand Up @@ -430,6 +436,19 @@ def cmd_health(tests: test_register.TestRegister, args, results):
print(f"{t.ljust(40, '.')} {found}")


# -----------------------------------------
# 'list-tags' command


def cmd_list_tags(tests: test_register.TestRegister, args, results: db.TestResults):
tags = tests.count_tags()
if not tags:
print("No tags defined.")
return
for tag, count in sorted(tags.items()):
Comment thread
lorelei-sakai marked this conversation as resolved.
print(f" {tag.ljust(20)} {count} tests")


# -----------------------------------------
# Command line parser

Expand Down Expand Up @@ -460,6 +479,12 @@ def arg_filter(p):
help="Select tests that match _all_ filters",
action="store_true",
)
p.add_argument(
"--tags",
metavar="EXPRESSION",
type=str,
help="select tests matching the tag expression (e.g. '!experimental')",
)


def build_filter(args):
Expand All @@ -480,6 +505,22 @@ def build_filter(args):
else:
top_filter.add_sub_filter(filter.StateFilter(s))

# CLI --tags overrides config [filter].tags
tag_expression = getattr(args, "tags", None)
if tag_expression is None:
try:
cfg = config.read_config()
tag_expression = cfg.get("tags")
except (FileNotFoundError, ValueError):
pass

if tag_expression:
matcher = tag_expr.parse_tag_expression(tag_expression)
combined = filter.AndFilter()
combined.add_sub_filter(top_filter)
combined.add_sub_filter(filter.TagFilter(matcher))
return combined

return top_filter


Expand Down Expand Up @@ -537,6 +578,12 @@ def command_line_parser():
arg_filter(list_p)
arg_result_set(list_p)
arg_run_nr(list_p)
list_p.add_argument(
"-T",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth adding a long form of this argument too, like --show-tags?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I initially chose -T since the verb-prefixed --show-tags felt redundant to the list subcommand, but I could add both for discoverability.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The verb is kind of redundant, yeah. The longer tag could be just --tags? It just feels weird to only have the short option when most other command arguments don't do that.

@mingnus mingnus Jul 2, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--tags is used for specifying tag expression and the list subcommand supports --tags, so the name is taken, unless we rename the --tags option. I haven't figured out a good long option name for -T.

pytest's -m option (marker expression) doesn't have the correspond long option name.

help="Show tags alongside test names",
action="store_true",
dest="show_tags",
)

log_p = subparsers.add_parser("log", help="list test logs")
log_p.set_defaults(func=cmd_log)
Expand Down Expand Up @@ -588,6 +635,9 @@ def command_line_parser():
help="only show runs whose result matches the given state",
)

list_tags_p = subparsers.add_parser("list-tags", help="list all tags with test counts")
list_tags_p.set_defaults(func=cmd_list_tags)

health_p = subparsers.add_parser(
"health", help="check required tools are installed"
)
Expand Down
24 changes: 12 additions & 12 deletions src/dmtest/bufio/bufio_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,12 +292,12 @@ def bufio_tester(data_dev, **opts):


def t_create(fix):
with bufio_tester(fix.cfg["data_dev"]):
with bufio_tester(fix.cfg("data_dev")):
pass


def t_empty_program(fix):
with bufio_tester(fix.cfg["data_dev"]) as tester:
with bufio_tester(fix.cfg("data_dev")) as tester:
with tester.program():
pass

Expand All @@ -318,14 +318,14 @@ def t_new_buf(fix):
nr_threads = 16
nr_gets = 1024

with bufio_tester(fix.cfg["data_dev"]) as tester:
with bufio_tester(fix.cfg("data_dev")) as tester:
for t in range(nr_threads):
with tester.program() as p:
do_new_buf(p, t * nr_gets)


def t_stamper(fix):
with bufio_tester(fix.cfg["data_dev"]) as tester:
with bufio_tester(fix.cfg("data_dev")) as tester:
with tester.program() as p:
block = p.alloc_reg()
buf = p.alloc_reg()
Expand Down Expand Up @@ -386,14 +386,14 @@ def t_many_stampers(fix):
nr_threads = 16
nr_gets = 1024

with bufio_tester(fix.cfg["data_dev"]) as tester:
with bufio_tester(fix.cfg("data_dev")) as tester:
for t in range(nr_threads):
with tester.program() as p:
do_stamper(p, t * nr_gets)


def t_writeback_nothing(fix):
data_dev = fix.cfg["data_dev"]
data_dev = fix.cfg("data_dev")
nr_blocks = units.meg(512) // units.kilo(4)

with bufio_tester(data_dev) as tester:
Expand All @@ -417,7 +417,7 @@ def t_writeback_nothing(fix):


def do_writes_hit_disk(fix, write_method):
data_dev = fix.cfg["data_dev"]
data_dev = fix.cfg("data_dev")
nr_blocks = units.meg(128) // units.kilo(4)
pattern_base = random.randint(0, 10240)

Expand Down Expand Up @@ -477,7 +477,7 @@ def async_write(p):


def t_writeback_many(fix):
data_dev = fix.cfg["data_dev"]
data_dev = fix.cfg("data_dev")
nr_blocks = units.gig(8) // units.kilo(4)

with bufio_tester(data_dev) as tester:
Expand Down Expand Up @@ -510,7 +510,7 @@ def t_hotspots(fix):

big_region_size = units.gig(1) // units.kilo(4)

with bufio_tester(fix.cfg["data_dev"]) as tester:
with bufio_tester(fix.cfg("data_dev")) as tester:
# hotspot programs
for b, e in regions:
with tester.program() as p:
Expand Down Expand Up @@ -545,7 +545,7 @@ def t_hotspots2(fix):

big_region_size = units.gig(1) // units.kilo(4)

with bufio_tester(fix.cfg["data_dev"]) as tester:
with bufio_tester(fix.cfg("data_dev")) as tester:
# hotspot programs
for b, e in regions:
with tester.program() as p:
Expand Down Expand Up @@ -594,7 +594,7 @@ def volume_name(index):
nr_blocks = volume_size // units.kilo(4)

vm = tvm.VM()
vm.add_allocation_volume(fix.cfg["data_dev"])
vm.add_allocation_volume(fix.cfg("data_dev"))

for i in range(nr_caches):
vm.add_volume(tvm.LinearVolume(volume_name(i), volume_size))
Expand All @@ -616,7 +616,7 @@ def volume_name(index):

# Checks that buffers that haven't been used for a while get evicted.
def t_evict_old(fix):
data_dev = fix.cfg["data_dev"]
data_dev = fix.cfg("data_dev")
nr_blocks = units.gig(1) // units.kilo(4)
data_size = utils.dev_size(data_dev)
t = table.Table(targets.BufioTestTarget(data_size, data_dev))
Expand Down
45 changes: 20 additions & 25 deletions src/dmtest/cache/resize_origin_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,10 @@ def check_sized_metadata(cmeta, old_cache_dump, new_origin_size):


def t_expand_origin_with_reload(fix):
cfg = fix.cfg
fast_dev = cfg["metadata_dev"]
origin_dev = cfg["data_dev"]
cache_dev = cfg.get("cache_dev", None)
policy_name = cfg.get("cache_policy", "smq")
fast_dev = fix.cfg("metadata_dev")
origin_dev = fix.cfg("data_dev")
cache_dev = fix.cfg("cache_dev")
policy_name = fix.cfg("cache_policy")

block_size = units.kilo(32)
cache_size = units.meg(128)
Expand Down Expand Up @@ -120,11 +119,10 @@ def t_expand_origin_with_reload(fix):


def t_shrink_origin_with_reload_drops_mappings(fix):
cfg = fix.cfg
fast_dev = cfg["metadata_dev"]
origin_dev = cfg["data_dev"]
cache_dev = cfg.get("cache_dev", None)
policy_name = cfg.get("cache_policy", "smq")
fast_dev = fix.cfg("metadata_dev")
origin_dev = fix.cfg("data_dev")
cache_dev = fix.cfg("cache_dev")
policy_name = fix.cfg("cache_policy")

block_size = units.kilo(32)
cache_size = units.meg(128)
Expand Down Expand Up @@ -166,11 +164,10 @@ def t_shrink_origin_with_reload_drops_mappings(fix):
# the origin, as we always have to load a new dm-cache table to change the
# target length. Here we test both the approaches to ensure test coverage.
def t_shrink_origin_with_teardown_drops_mappings(fix):
cfg = fix.cfg
fast_dev = cfg["metadata_dev"]
origin_dev = cfg["data_dev"]
cache_dev = cfg.get("cache_dev", None)
policy_name = cfg.get("cache_policy", "smq")
fast_dev = fix.cfg("metadata_dev")
origin_dev = fix.cfg("data_dev")
cache_dev = fix.cfg("cache_dev")
policy_name = fix.cfg("cache_policy")

block_size = units.kilo(32)
cache_size = units.meg(128)
Expand Down Expand Up @@ -210,11 +207,10 @@ def t_shrink_origin_with_teardown_drops_mappings(fix):


def t_shrink_origin_with_reload_should_fail_if_blocks_dirty(fix):
cfg = fix.cfg
fast_dev = cfg["metadata_dev"]
origin_dev = cfg["data_dev"]
cache_dev = cfg.get("cache_dev", None)
policy_name = cfg.get("cache_policy", "smq")
fast_dev = fix.cfg("metadata_dev")
origin_dev = fix.cfg("data_dev")
cache_dev = fix.cfg("cache_dev")
policy_name = fix.cfg("cache_policy")

block_size = units.kilo(32)
cache_size = units.meg(128)
Expand Down Expand Up @@ -255,11 +251,10 @@ def t_shrink_origin_with_reload_should_fail_if_blocks_dirty(fix):


def t_shrink_origin_with_teardown_should_fail_if_blocks_dirty(fix):
cfg = fix.cfg
fast_dev = cfg["metadata_dev"]
origin_dev = cfg["data_dev"]
cache_dev = cfg.get("cache_dev", None)
policy_name = cfg.get("cache_policy", "smq")
fast_dev = fix.cfg("metadata_dev")
origin_dev = fix.cfg("data_dev")
cache_dev = fix.cfg("cache_dev")
policy_name = fix.cfg("cache_policy")

block_size = units.kilo(32)
cache_size = units.meg(128)
Expand Down
9 changes: 4 additions & 5 deletions src/dmtest/cache/small_config_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,10 @@
#----------------------------------------------------------------

def t_small_config(fix):
cfg = fix.cfg
fast_dev = cfg["metadata_dev"]
origin_dev = cfg["data_dev"]
cache_dev = cfg.get("cache_dev", None)
policy_name = cfg.get("cache_policy", "smq")
fast_dev = fix.cfg("metadata_dev")
origin_dev = fix.cfg("data_dev")
cache_dev = fix.cfg("cache_dev")
policy_name = fix.cfg("cache_policy")

stack = ManagedCacheStack(
fast_dev,
Expand Down
48 changes: 38 additions & 10 deletions src/dmtest/config.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,55 @@
import toml


_CONFIG_KEYS = {
"metadata_dev": ("devices", None),
"data_dev": ("devices", None),
"cache_dev": ("devices", None),
"disable_by_id_check": ("devices", False),
"tags": ("filter", None),
"cache_policy": ("run", "smq"),
}


class Config:
def __init__(self, raw):
self._raw = raw

def get(self, key):
entry = _CONFIG_KEYS.get(key)
if entry is None:
raise KeyError(f"unknown config key: {key}")
section, default = entry
return self._raw.get(section, {}).get(key, default)


# Linux reordered my nvme drives once and I ran tests across
# /boot. This check tries to avoid that. The exception is
# virt devices, which don't seem to have an id.
def check_dev(cfg, name):
if cfg.get("disable_by_id_check", False):
return

value = cfg[name]
def check_dev(value, name):
if not (
value.startswith("/dev/vd") or value.startswith("/dev/mapper/")
) and not value.startswith("/dev/disk/by-id/"):
raise ValueError(f"config value '{name}' does not begin with /dev/disk/by-id")


def validate(cfg):
check_dev(cfg, "metadata_dev")
check_dev(cfg, "data_dev")
if "devices" not in cfg._raw:
raise ValueError(
"config.toml must have a [devices] section; "
"see config.toml.example for the expected format"
)
for key in ("metadata_dev", "data_dev"):
if cfg.get(key) is None:
raise ValueError(f"config.toml: missing required key '{key}' in [devices]")
if not cfg.get("disable_by_id_check"):
check_dev(cfg.get("metadata_dev"), "metadata_dev")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check_dev here really doesn't operation on anything except the config, so it probably makes more sense as a function in the Config class.
Then you could call it simply as cfg.check_dev("data_dev") with fewer arguments.

Possibly the entire validate() function should also be defined in the Config class as well, though that makes less difference in terms of code complexity.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I'll do that.

check_dev(cfg.get("data_dev"), "data_dev")


def read_config(path="config.toml"):
with open(path, "r") as f:
config = toml.load(f)
validate(config)
return config
raw = toml.load(f)
cfg = Config(raw)
validate(cfg)
return cfg
Loading