Skip to content

Commit 5394b82

Browse files
authored
Rename testdata.yaml to test_group.yaml and implement Test Case Configuration (#456)
* Rename testdata.yaml to test_group.yaml Also took the time to replace some occurrences of test{case,group,node} with test{ ,_}{case,group,node}. Verified by grepping `(?<![Cc]heck)testdata` that the phrase "testdata" no longer occurs, except in upgrade.py. * [schemas] Update schemas for test_group.yaml * Introduce Test Case Configuration .yaml file, move .hint and .desc to there * Do not allow an unnumbered test case to be named 'test_group' This is also mentioned in https://icpc.io/problem-package-format/spec/2023-07-draft.html#test-data * [schemas] Move `test_group.yaml` from `data` to top-level
1 parent 2ce5c87 commit 5394b82

32 files changed

+715
-492
lines changed

bin/config.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,7 @@
7272
*KNOWN_TESTCASE_EXTENSIONS,
7373
*KNOWN_SAMPLE_TESTCASE_EXTENSIONS,
7474
".interaction",
75-
".hint",
76-
".desc",
77-
#'.args',
75+
".yaml",
7876
]
7977

8078
KNOWN_DATA_EXTENSIONS: Final[Sequence[str]] = [

bin/export.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ def add_testcase(in_file: Path) -> None:
213213
# substitute constants.
214214
if problem.settings.constants:
215215
constants_supported = [
216-
"data/**/testdata.yaml",
216+
"data/**/test_group.yaml",
217217
f"{InputValidator.source_dir}/**/*",
218218
f"{AnswerValidator.source_dir}/**/*",
219219
f"{OutputValidator.source_dir}/**/*",
@@ -298,7 +298,7 @@ def add_testcase(in_file: Path) -> None:
298298
ryaml_filter(limits, "time_limit")
299299
# validator_flags
300300
validator_flags = " ".join(
301-
problem.get_testdata_yaml(
301+
problem.get_test_case_yaml(
302302
problem.path / "data",
303303
OutputValidator.args_key,
304304
PrintBar("Getting validator_flags for legacy export"),
@@ -325,13 +325,6 @@ def add_testcase(in_file: Path) -> None:
325325
else:
326326
util.error(f"{f}: no name set for language {lang}.")
327327

328-
# rename output_validator dir
329-
if (export_dir / OutputValidator.source_dir).exists():
330-
(export_dir / "output_validators").mkdir(parents=True)
331-
(export_dir / OutputValidator.source_dir).rename(
332-
export_dir / "output_validators" / OutputValidator.source_dir
333-
)
334-
335328
# rename statement dirs
336329
if (export_dir / "statement").exists():
337330
(export_dir / "statement").rename(export_dir / "problem_statement")
@@ -352,6 +345,18 @@ def add_testcase(in_file: Path) -> None:
352345
add_file(out, f)
353346
shutil.rmtree(export_dir / d)
354347

348+
# rename output_validator dir
349+
if (export_dir / OutputValidator.source_dir).exists():
350+
(export_dir / "output_validators").mkdir(parents=True)
351+
(export_dir / OutputValidator.source_dir).rename(
352+
export_dir / "output_validators" / OutputValidator.source_dir
353+
)
354+
355+
# rename test_group.yaml back to testdata.yaml
356+
for f in (export_dir / "data").rglob("test_group.yaml"):
357+
f.rename(f.with_name("testdata.yaml"))
358+
# TODO potentially, some keys also need to be renamed, but we don't use this often enough for this to matter (I hope)
359+
355360
# handle yaml updates
356361
yaml_path.unlink()
357362
write_yaml(yaml_data, yaml_path)

bin/generate.py

Lines changed: 99 additions & 84 deletions
Large diffs are not rendered by default.

bin/interactive.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def get_validator_command():
5656
run.testcase.ans_path.resolve(),
5757
run.feedbackdir.resolve(),
5858
]
59-
+ run.testcase.testdata_yaml_args(
59+
+ run.testcase.test_case_yaml_args(
6060
output_validator,
6161
bar or PrintBar("Run interactive test case"),
6262
)

bin/problem.py

Lines changed: 108 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,9 @@ def __init__(
283283
self.limits = ProblemLimits(parse_setting(yaml_data, "limits", {}), problem, self)
284284

285285
parse_deprecated_setting(
286-
yaml_data, "validator_flags", f"{validate.OutputValidator.args_key}' in 'testdata.yaml"
286+
yaml_data,
287+
"validator_flags",
288+
f"{validate.OutputValidator.args_key}' in 'test_group.yaml",
287289
)
288290

289291
self.keywords: list[str] = parse_optional_list_setting(yaml_data, "keywords", str)
@@ -362,9 +364,9 @@ def __init__(self, path: Path, tmpdir: Path, label: Optional[str] = None):
362364
self._programs = dict[Path, "Program"]()
363365
self._program_callbacks = dict[Path, list[Callable[["Program"], None]]]()
364366
# Dictionary from path to parsed file contents.
365-
# TODO #102: Add type for testdata.yaml (typed Namespace?)
366-
self._testdata_yamls = dict[Path, dict[str, Any]]()
367-
self._testdata_lock = threading.Lock()
367+
# TODO #102: Add type for test_group.yaml (typed Namespace?)
368+
self._test_case_yamls = dict[Path, dict[str, Any]]()
369+
self._test_group_lock = threading.Lock()
368370

369371
# The label for the problem: A, B, A1, A2, X, ...
370372
self.label = label
@@ -457,105 +459,102 @@ def _read_settings(self):
457459
self.multi_pass: bool = self.settings.multi_pass
458460
self.custom_output: bool = self.settings.custom_output
459461

460-
# TODO #102 move to TestData class
461-
def _parse_testdata_yaml(p, path, bar):
462+
# TODO #102 move to a new TestGroup class
463+
def _parse_test_case_and_groups_yaml(p, path: Path, bar: BAR_TYPE):
462464
assert path.is_relative_to(p.path / "data")
463-
for dir in [path] + list(path.parents):
465+
for f in [path] + list(path.parents):
464466
# Do not go above the data directory.
465-
if dir == p.path:
467+
if f == p.path:
466468
return
467469

468-
f = dir / "testdata.yaml"
469-
if not f.is_file() or f in p._testdata_yamls:
470-
continue
471-
with p._testdata_lock:
472-
if f not in p._testdata_yamls:
473-
raw = substitute(
474-
f.read_text(),
475-
p.settings.constants,
476-
pattern=config.CONSTANT_SUBSTITUTE_REGEX,
477-
)
478-
p._testdata_yamls[f] = flags = parse_yaml(raw, path=f, plain=True)
470+
if f.is_dir():
471+
f = f / "test_group.yaml"
472+
with p._test_group_lock:
473+
if not f.is_file() or f in p._test_case_yamls:
474+
continue
475+
raw = substitute(
476+
f.read_text(),
477+
p.settings.constants,
478+
pattern=config.CONSTANT_SUBSTITUTE_REGEX,
479+
)
480+
p._test_case_yamls[f] = flags = parse_yaml(raw, path=f, plain=True)
479481

480-
parse_deprecated_setting(
481-
flags, "output_validator_flags", validate.OutputValidator.args_key
482-
)
483-
parse_deprecated_setting(
484-
flags, "input_validator_flags", validate.InputValidator.args_key
485-
)
482+
parse_deprecated_setting(
483+
flags, "output_validator_flags", validate.OutputValidator.args_key
484+
)
485+
parse_deprecated_setting(
486+
flags, "input_validator_flags", validate.InputValidator.args_key
487+
)
486488

487-
# Verify testdata.yaml
488-
for k in flags:
489-
match k:
490-
case (
491-
validate.OutputValidator.args_key
492-
| validate.AnswerValidator.args_key
493-
| visualize.TestCaseVisualizer.args_key
494-
| visualize.OutputVisualizer.args_key
495-
):
496-
if not isinstance(flags[k], list):
497-
bar.error(
498-
f"{k} must be a list of strings",
499-
resume=True,
500-
print_item=False,
501-
)
502-
case validate.InputValidator.args_key:
503-
if not isinstance(flags[k], (list, dict)):
504-
bar.error(
505-
f"{k} must be list or map",
506-
resume=True,
507-
print_item=False,
508-
)
509-
if isinstance(flags[k], dict):
510-
input_validator_names = set(
511-
val.name for val in p.validators(validate.InputValidator)
512-
)
513-
for name in set(flags[k]) - input_validator_names:
514-
bar.warn(
515-
f"Unknown input validator {name}; expected {input_validator_names}",
516-
print_item=False,
517-
)
518-
case (
519-
"args"
520-
| "description"
521-
| "full_feedback"
522-
| "hint"
523-
| "scoring"
524-
| "static_validation"
525-
):
526-
bar.warn(
527-
f"{k} in testdata.yaml not implemented in BAPCtools",
528-
print_item=False,
489+
# Use variable kwargs so the type checker does not complain when passing them to a PrintBar (nothing happens in that case anyway)
490+
bar_kwargs = {"resume": True, "print_item": False}
491+
492+
# Verify test_group.yaml
493+
for k in flags:
494+
match k:
495+
case (
496+
validate.OutputValidator.args_key
497+
| validate.AnswerValidator.args_key
498+
| visualize.TestCaseVisualizer.args_key
499+
| visualize.OutputVisualizer.args_key
500+
):
501+
if not isinstance(flags[k], list):
502+
bar.error(
503+
f"{k} must be a list of strings",
504+
None,
505+
**bar_kwargs,
529506
)
530-
case _:
531-
path = f.relative_to(p.path / "data")
532-
bar.warn(f'Unknown key "{k}" in {path}', print_item=False)
533-
# Do not go above the data directory.
534-
if dir == p.path / "data":
535-
break
536-
537-
def get_testdata_yaml(
507+
case validate.InputValidator.args_key:
508+
if not isinstance(flags[k], (list, dict)):
509+
bar.error(
510+
f"{k} must be list or map",
511+
None,
512+
**bar_kwargs,
513+
)
514+
if isinstance(flags[k], dict):
515+
input_validator_names = set(
516+
val.name for val in p.validators(validate.InputValidator)
517+
)
518+
for name in set(flags[k]) - input_validator_names:
519+
bar.warn(
520+
f"Unknown input validator {name}; expected {input_validator_names}",
521+
None,
522+
**bar_kwargs,
523+
)
524+
case "description" | "hint":
525+
pass # We don't do anything with hint or description in BAPCtools, but no need to warn about this
526+
case "args" | "full_feedback" | "scoring" | "static_validation":
527+
bar.warn(
528+
f"{k} in test_group.yaml not implemented in BAPCtools",
529+
None,
530+
**bar_kwargs,
531+
)
532+
case _:
533+
path = f.relative_to(p.path / "data")
534+
bar.warn(f'Unknown key "{k}" in {path}', None, **bar_kwargs)
535+
536+
def get_test_case_yaml(
538537
p,
539538
path: Path,
540539
key: str,
541540
bar: BAR_TYPE,
542541
name: Optional[str] = None,
543542
) -> list[str]:
544543
"""
545-
Find the testdata flags applying at the given path for the given key.
546-
If necessary, walk up from `path` looking for the first testdata.yaml file that applies,
544+
Find the value of the given test_group.yaml key applying at the given path.
545+
If necessary, walk up from `path` looking for the first test_group.yaml file that applies.
547546
548547
Side effects: parses and caches the file.
549548
550549
Arguments
551550
---------
552551
path: absolute path (a file or a directory)
553-
key: The testdata.yaml key to look for (TODO: 'grading' is not yet implemented)
552+
key: The test_group.yaml key to look for (TODO: 'grading' is not yet implemented)
554553
name: If key == 'input_validator_args', optionally the name of the input validator.
555554
556555
Returns:
557556
--------
558-
A list of string arguments, which is empty if no testdata.yaml is found.
557+
A list of string arguments, which is empty if no test_group.yaml is found.
559558
TODO: when 'grading' is supported, it also can return dict
560559
"""
561560
known_args_keys = [
@@ -572,19 +571,21 @@ def get_testdata_yaml(
572571
f"Only input validators support flags by validator name, got {key} and {name}"
573572
)
574573

575-
# parse and cache testdata.yaml
576-
p._parse_testdata_yaml(path, bar)
574+
# parse and cache <test_case>.yaml and test_group.yaml
575+
path = path.with_suffix(".yaml")
576+
p._parse_test_case_and_groups_yaml(path, bar)
577577

578578
# extract the flags
579-
for dir in [path] + list(path.parents):
579+
for f in [path] + list(path.parents):
580580
# Do not go above the data directory.
581-
if dir == p.path:
581+
if f == p.path:
582582
return []
583583

584-
f = dir / "testdata.yaml"
585-
if f not in p._testdata_yamls:
584+
if f.suffix != ".yaml":
585+
f = f / "test_group.yaml"
586+
if f not in p._test_case_yamls:
586587
continue
587-
flags = p._testdata_yamls[f]
588+
flags = p._test_case_yamls[f]
588589
if key in flags:
589590
args = flags[key]
590591
if key == validate.InputValidator.args_key:
@@ -611,6 +612,15 @@ def get_testdata_yaml(
611612

612613
return []
613614

615+
# Because Problem.testcases() may be called multiple times (e.g. validating multiple modes, or with `bt all`),
616+
# this cache makes sure that some warnings (like malformed test case names) only appear once.
617+
_warned_for_test_case = set[str]()
618+
619+
def _warn_once(p, test_name, msg):
620+
if test_name not in p._warned_for_test_case:
621+
p._warned_for_test_case.add(test_name)
622+
warn(msg)
623+
614624
def testcases(
615625
p,
616626
*,
@@ -659,6 +669,15 @@ def testcases(
659669
testcases = []
660670
for f in in_paths:
661671
t = testcase.Testcase(p, f)
672+
if not config.COMPILED_FILE_NAME_REGEX.fullmatch(f.name):
673+
p._warn_once(t.name, f"Test case name {t.name} is not valid. Skipping.")
674+
continue
675+
if f.with_suffix("").name == "test_group":
676+
p._warn_once(
677+
t.name,
678+
"Test case must not be named 'test_group', this clashes with the group-level 'test_group.yaml'. Skipping.",
679+
)
680+
continue
662681
if (
663682
(p.interactive or p.multi_pass)
664683
and mode in [validate.Mode.INVALID, validate.Mode.VALID_OUTPUT]
@@ -670,7 +689,7 @@ def testcases(
670689
continue
671690
if needans and not t.ans_path.is_file():
672691
if t.root != "invalid_input":
673-
warn(f"Found input file {f} without a .ans file. Skipping.")
692+
p._warn_once(t.name, f"Found input file {f} without a .ans file. Skipping.")
674693
continue
675694
if mode == validate.Mode.VALID_OUTPUT:
676695
if t.out_path is None:
@@ -1331,7 +1350,7 @@ def validate_valid_extra_data(p) -> bool:
13311350
if not p.validators(validate.OutputValidator, strict=True, print_warn=False):
13321351
return True
13331352

1334-
args = p.get_testdata_yaml(
1353+
args = p.get_test_case_yaml(
13351354
p.path / "data" / "valid_output",
13361355
"output_validator_args",
13371356
PrintBar("Generic Output Validation"),
@@ -1492,7 +1511,7 @@ def run_all(select_verdict, select):
14921511
return None, None, None
14931512

14941513
def get_slowest(result):
1495-
slowest_pair = result.slowest_testcase()
1514+
slowest_pair = result.slowest_test_case()
14961515
assert slowest_pair is not None
14971516
return slowest_pair
14981517

bin/run.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ def _validate_output(self, bar: BAR_TYPE) -> Optional[ExecResult]:
228228
return output_validator.run(
229229
self.testcase,
230230
self,
231-
args=self.testcase.testdata_yaml_args(output_validator, bar),
231+
args=self.testcase.test_case_yaml_args(output_validator, bar),
232232
)
233233

234234
def _visualize_output(self, bar: BAR_TYPE) -> Optional[ExecResult]:
@@ -242,7 +242,7 @@ def _visualize_output(self, bar: BAR_TYPE) -> Optional[ExecResult]:
242242
self.testcase.ans_path.resolve(),
243243
self.out_path if not self.problem.interactive else None,
244244
self.feedbackdir,
245-
args=self.testcase.testdata_yaml_args(output_visualizer, bar),
245+
args=self.testcase.test_case_yaml_args(output_visualizer, bar),
246246
)
247247

248248

@@ -501,15 +501,15 @@ def process_run(run: Run):
501501
else:
502502
color = Fore.GREEN if self.verdict in self.expected_verdicts else Fore.RED
503503

504-
(salient_testcase, salient_duration) = verdicts.salient_testcase()
504+
(salient_testcase, salient_duration) = verdicts.salient_test_case()
505505
salient_print_verdict = self.verdict
506506
salient_duration_style = Style.BRIGHT if salient_duration >= self.limits["timeout"] else ""
507507

508508
# Summary line is the only thing shown.
509509
message = f"{color}{salient_print_verdict.short():>3}{salient_duration_style}{salient_duration:6.3f}s{Style.RESET_ALL} {Style.DIM}@ {salient_testcase:{max_testcase_len}}{Style.RESET_ALL}"
510510

511511
if verdicts.run_until in [RunUntil.DURATION, RunUntil.ALL]:
512-
slowest_pair = verdicts.slowest_testcase()
512+
slowest_pair = verdicts.slowest_test_case()
513513
assert slowest_pair is not None
514514
(slowest_testcase, slowest_duration) = slowest_pair
515515
slowest_verdict = verdicts[slowest_testcase]

0 commit comments

Comments
 (0)