rbx.cp 0.5.36__py3-none-any.whl → 0.5.38__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rbx/box/solutions.py CHANGED
@@ -7,7 +7,6 @@ import shutil
7
7
  from collections.abc import Iterator
8
8
  from typing import Dict, Iterable, List, Optional, Set, Tuple
9
9
 
10
- import questionary
11
10
  import rich
12
11
  import rich.live
13
12
  import rich.markup
@@ -29,7 +28,6 @@ from rbx.box.formatting import get_formatted_memory, get_formatted_time
29
28
  from rbx.box.generators import (
30
29
  GenerationMetadata,
31
30
  expand_generator_call,
32
- extract_generation_testcases,
33
31
  generate_output_for_testcase,
34
32
  generate_standalone,
35
33
  )
@@ -42,6 +40,7 @@ from rbx.box.schema import (
42
40
  Testcase,
43
41
  TestcaseGroup,
44
42
  )
43
+ from rbx.box.testcase_extractors import extract_generation_testcases
45
44
  from rbx.box.testcase_utils import TestcaseEntry, find_built_testcases
46
45
  from rbx.grading.steps import (
47
46
  DigestOrDest,
@@ -674,6 +673,8 @@ def pick_solutions(tracked_solutions: Optional[Set[str]]) -> List[str]:
674
673
  tracked_solutions = set(str(sol.path) for sol in pkg.solutions)
675
674
 
676
675
  # Store in a separate list to maintain order with the package declaration.
676
+ import questionary
677
+
677
678
  choices = [
678
679
  questionary.Choice(title=_get_solution_repr(sol), value=str(sol.path))
679
680
  for sol in pkg.solutions
rbx/box/solutions_test.py CHANGED
@@ -5,7 +5,6 @@ import pytest
5
5
 
6
6
  from rbx.box.environment import VerificationLevel
7
7
  from rbx.box.generators import (
8
- extract_generation_testcases_from_groups,
9
8
  generate_outputs_for_testcases,
10
9
  generate_testcases,
11
10
  )
@@ -13,6 +12,7 @@ from rbx.box.solutions import (
13
12
  convert_list_of_solution_evaluations_to_dict,
14
13
  run_solutions,
15
14
  )
15
+ from rbx.box.testcase_extractors import extract_generation_testcases_from_groups
16
16
  from rbx.grading.steps import Outcome
17
17
 
18
18
 
@@ -6,7 +6,7 @@ from typing import Annotated, Dict, List, Optional, Tuple
6
6
  import typer
7
7
 
8
8
  from rbx import annotations, console
9
- from rbx.box import builder, environment, package
9
+ from rbx.box import environment, package
10
10
  from rbx.box.schema import Package
11
11
  from rbx.box.statements.builders import (
12
12
  BUILDER_LIST,
@@ -333,6 +333,8 @@ def build(
333
333
  ):
334
334
  # At most run the validators, only in samples.
335
335
  if samples:
336
+ from rbx.box import builder
337
+
336
338
  if not builder.build(
337
339
  verification=verification,
338
340
  groups=set(['samples']),
@@ -11,12 +11,6 @@ from pydantic import BaseModel
11
11
 
12
12
  from rbx import console, utils
13
13
  from rbx.box.schema import Package, Primitive, Testcase
14
- from rbx.box.statements.latex import (
15
- MAX_PDFLATEX_RUNS,
16
- Latex,
17
- decode_latex_output,
18
- should_rerun,
19
- )
20
14
  from rbx.box.statements.latex_jinja import (
21
15
  JinjaDictWrapper,
22
16
  render_latex_template,
@@ -355,6 +349,13 @@ class TeX2PDFBuilder(StatementBuilder):
355
349
  item: StatementBuilderItem,
356
350
  verbose: bool = False,
357
351
  ) -> bytes:
352
+ from rbx.box.statements.latex import (
353
+ MAX_PDFLATEX_RUNS,
354
+ Latex,
355
+ decode_latex_output,
356
+ should_rerun,
357
+ )
358
+
358
359
  latex = Latex(input.decode())
359
360
  latex_result = latex.build_pdf(context.root)
360
361
  pdf = latex_result.pdf
@@ -10,12 +10,6 @@ from rbx.box.statements.builders import (
10
10
  StatementBuilderContest,
11
11
  StatementCodeLanguage,
12
12
  )
13
- from rbx.box.statements.latex import (
14
- MAX_PDFLATEX_RUNS,
15
- Latex,
16
- decode_latex_output,
17
- should_rerun,
18
- )
19
13
  from rbx.box.statements.schema import Joiner, JoinerType, JoinTexToPDF, StatementType
20
14
 
21
15
 
@@ -84,6 +78,13 @@ class TeX2PDFJoiner(StatementJoiner):
84
78
  contest: StatementBuilderContest,
85
79
  verbose: bool = False,
86
80
  ) -> bytes:
81
+ from rbx.box.statements.latex import (
82
+ MAX_PDFLATEX_RUNS,
83
+ Latex,
84
+ decode_latex_output,
85
+ should_rerun,
86
+ )
87
+
87
88
  latex = Latex(input.decode())
88
89
  latex_result = latex.build_pdf(context.root)
89
90
  pdf = latex_result.pdf
@@ -0,0 +1,348 @@
1
+ import abc
2
+ import pathlib
3
+ import shlex
4
+ from typing import Iterable, List, Optional, Set, Tuple
5
+
6
+ import typer
7
+ from pydantic import BaseModel
8
+
9
+ from rbx import console
10
+ from rbx.box import package
11
+ from rbx.box.code import compile_item, run_item
12
+ from rbx.box.schema import CodeItem, GeneratorCall, Testcase, TestcaseSubgroup
13
+ from rbx.box.testcase_utils import (
14
+ TestcaseEntry,
15
+ TestcasePattern,
16
+ fill_output_for_defined_testcase,
17
+ )
18
+ from rbx.grading.steps import DigestHolder, DigestOrDest, DigestOrSource
19
+
20
+
21
+ def _get_group_input(
22
+ group_path: pathlib.Path, subgroup_prefix: str, i: int
23
+ ) -> pathlib.Path:
24
+ return group_path / f'{subgroup_prefix}{i:03d}.in'
25
+
26
+
27
+ def _get_group_output(
28
+ group_path: pathlib.Path, subgroup_prefix: str, i: int
29
+ ) -> pathlib.Path:
30
+ return group_path / f'{subgroup_prefix}{i:03d}.out'
31
+
32
+
33
+ def _run_generator_script(testcase: TestcaseSubgroup) -> str:
34
+ assert testcase.generatorScript is not None
35
+
36
+ cacher = package.get_file_cacher()
37
+
38
+ if not testcase.generatorScript.path.is_file():
39
+ console.console.print(
40
+ f'[error]Generator script not found: [item]{testcase.generatorScript.path}[/item][/error]'
41
+ )
42
+ raise typer.Exit(1)
43
+
44
+ script_digest = DigestHolder()
45
+ if testcase.generatorScript.path.suffix == '.txt':
46
+ script_digest.value = cacher.put_file_from_path(testcase.generatorScript.path)
47
+ else:
48
+ try:
49
+ compiled_digest = compile_item(testcase.generatorScript)
50
+ except:
51
+ console.console.print(
52
+ f'[error]Failed compiling generator script for group [item]{testcase.name}[/item].[/error]'
53
+ )
54
+ raise
55
+
56
+ run_stderr = DigestHolder()
57
+ run_log = run_item(
58
+ testcase.generatorScript,
59
+ DigestOrSource.create(compiled_digest),
60
+ stdout=DigestOrDest.create(script_digest),
61
+ stderr=DigestOrDest.create(run_stderr),
62
+ )
63
+
64
+ if run_log is None or run_log.exitcode != 0:
65
+ console.console.print(
66
+ f'Could not run generator script for group {testcase.name}'
67
+ )
68
+ if run_log is not None:
69
+ console.console.print(
70
+ f'[error]Summary:[/error] {run_log.get_summary()}'
71
+ )
72
+ if run_stderr.value is not None:
73
+ console.console.print('[error]Stderr:[/error]')
74
+ console.console.print(
75
+ package.get_digest_as_string(run_stderr.value) or ''
76
+ )
77
+ raise typer.Exit(1)
78
+
79
+ assert script_digest.value
80
+ script = cacher.get_file_content(script_digest.value).decode()
81
+ return script
82
+
83
+
84
+ def _extract_script_lines(script: str) -> Iterable[Tuple[str, str, int]]:
85
+ lines = script.splitlines()
86
+ for i, line in enumerate(lines):
87
+ line = line.strip()
88
+ if not line:
89
+ continue
90
+ if line.startswith('#'):
91
+ continue
92
+ yield shlex.split(line)[0], shlex.join(shlex.split(line)[1:]), i + 1
93
+
94
+
95
+ class GeneratorScriptEntry(BaseModel):
96
+ path: pathlib.Path
97
+ line: int
98
+
99
+
100
+ class GenerationMetadata(BaseModel):
101
+ copied_to: Testcase
102
+
103
+ copied_from: Optional[Testcase] = None
104
+ generator_call: Optional[GeneratorCall] = None
105
+ generator_script: Optional[GeneratorScriptEntry] = None
106
+
107
+
108
+ class GenerationTestcaseEntry(BaseModel):
109
+ group_entry: TestcaseEntry
110
+ subgroup_entry: TestcaseEntry
111
+
112
+ metadata: GenerationMetadata
113
+ validator: Optional[CodeItem] = None
114
+ extra_validators: List[CodeItem] = []
115
+
116
+
117
+ class TestcaseVisitor(abc.ABC):
118
+ @abc.abstractmethod
119
+ def visit(self, entry: GenerationTestcaseEntry):
120
+ pass
121
+
122
+ def should_visit_group(self, group_name: str) -> bool:
123
+ return True
124
+
125
+ def should_visit_subgroup(self, subgroup_path: str) -> bool:
126
+ return True
127
+
128
+ def should_visit_generator_scripts(
129
+ self, group_name: str, subgroup_path: str
130
+ ) -> bool:
131
+ return True
132
+
133
+
134
+ class TestcaseGroupVisitor(TestcaseVisitor):
135
+ def __init__(self, groups: Optional[Set[str]] = None):
136
+ self.groups = groups
137
+
138
+ def should_visit_group(self, group_name: str) -> bool:
139
+ return self.groups is None or group_name in self.groups
140
+
141
+
142
+ def run_testcase_visitor(visitor: TestcaseVisitor):
143
+ pkg = package.find_problem_package_or_die()
144
+
145
+ def _explore_subgroup(
146
+ subgroup: TestcaseSubgroup,
147
+ subgroup_index: Optional[int],
148
+ prefix: List[str],
149
+ validator: Optional[CodeItem] = None,
150
+ extra_validators: Optional[List[CodeItem]] = None,
151
+ ):
152
+ extra_validators = extra_validators or []
153
+
154
+ assert prefix and len(prefix) >= 1 and len(prefix) <= 2
155
+ group_path = prefix[0]
156
+ subgroup_path = '/'.join(prefix)
157
+ if not visitor.should_visit_subgroup(subgroup_path):
158
+ return
159
+
160
+ def _entry(i: int) -> TestcaseEntry:
161
+ return TestcaseEntry(group=group_path, index=i)
162
+
163
+ def _sub_entry(i: int) -> TestcaseEntry:
164
+ return TestcaseEntry(group=subgroup_path, index=i)
165
+
166
+ def _copied_to(i: int) -> Testcase:
167
+ group_fs_path = package.get_build_testgroup_path(group_path)
168
+ group_prefix = ''
169
+ if subgroup_index is not None:
170
+ group_prefix = f'{subgroup_index}-'
171
+ if len(prefix) == 2:
172
+ group_prefix += f'{prefix[1]}-'
173
+ return Testcase(
174
+ inputPath=_get_group_input(group_fs_path, group_prefix, i),
175
+ outputPath=_get_group_output(group_fs_path, group_prefix, i),
176
+ )
177
+
178
+ # Go through testcases.
179
+ i = 0
180
+ # Individual testcases.
181
+ for tc in subgroup.testcases or []:
182
+ visitor.visit(
183
+ GenerationTestcaseEntry(
184
+ group_entry=_entry(i),
185
+ subgroup_entry=_sub_entry(i),
186
+ metadata=GenerationMetadata(
187
+ copied_from=fill_output_for_defined_testcase(tc),
188
+ copied_to=_copied_to(i),
189
+ ),
190
+ validator=validator,
191
+ extra_validators=extra_validators,
192
+ )
193
+ )
194
+ i += 1
195
+
196
+ # Glob testcases.
197
+ if subgroup.testcaseGlob:
198
+ matched_inputs = sorted(pathlib.PosixPath().glob(subgroup.testcaseGlob))
199
+
200
+ for input_path in matched_inputs:
201
+ if not input_path.is_file() or input_path.suffix != '.in':
202
+ continue
203
+
204
+ tc = Testcase(inputPath=input_path)
205
+ visitor.visit(
206
+ GenerationTestcaseEntry(
207
+ group_entry=_entry(i),
208
+ subgroup_entry=_sub_entry(i),
209
+ metadata=GenerationMetadata(
210
+ copied_from=fill_output_for_defined_testcase(tc),
211
+ copied_to=_copied_to(i),
212
+ ),
213
+ validator=validator,
214
+ extra_validators=extra_validators,
215
+ )
216
+ )
217
+ i += 1
218
+
219
+ # Single generators.
220
+ for generator_call in subgroup.generators:
221
+ visitor.visit(
222
+ GenerationTestcaseEntry(
223
+ group_entry=_entry(i),
224
+ subgroup_entry=_sub_entry(i),
225
+ metadata=GenerationMetadata(
226
+ generator_call=generator_call,
227
+ copied_to=_copied_to(i),
228
+ ),
229
+ validator=validator,
230
+ extra_validators=extra_validators,
231
+ )
232
+ )
233
+ i += 1
234
+
235
+ if not visitor.should_visit_generator_scripts(group_path, subgroup_path):
236
+ return
237
+
238
+ # Run generator script.
239
+ if subgroup.generatorScript is not None:
240
+ script = _run_generator_script(subgroup)
241
+
242
+ # Run each line from generator script.
243
+ for generator_name, args, line_number in _extract_script_lines(script):
244
+ call = GeneratorCall(name=generator_name, args=args)
245
+ visitor.visit(
246
+ GenerationTestcaseEntry(
247
+ group_entry=_entry(i),
248
+ subgroup_entry=_sub_entry(i),
249
+ metadata=GenerationMetadata(
250
+ generator_call=call,
251
+ generator_script=GeneratorScriptEntry(
252
+ path=subgroup.generatorScript.path,
253
+ line=line_number,
254
+ ),
255
+ copied_to=_copied_to(i),
256
+ ),
257
+ validator=validator,
258
+ extra_validators=extra_validators,
259
+ )
260
+ )
261
+ i += 1
262
+
263
+ for group in pkg.testcases:
264
+ if not visitor.should_visit_group(group.name):
265
+ continue
266
+
267
+ group_validator = pkg.validator
268
+ if group.validator is not None:
269
+ group_validator = group.validator
270
+
271
+ extra_validators = group.extraValidators
272
+ _explore_subgroup(
273
+ group,
274
+ 0 if group.subgroups else None,
275
+ [group.name],
276
+ validator=group_validator,
277
+ extra_validators=extra_validators,
278
+ )
279
+
280
+ for i, subgroup in enumerate(group.subgroups):
281
+ _explore_subgroup(
282
+ subgroup,
283
+ i + 1,
284
+ [group.name, subgroup.name],
285
+ validator=group_validator,
286
+ extra_validators=extra_validators + subgroup.extraValidators,
287
+ )
288
+
289
+
290
+ def extract_generation_testcases(
291
+ entries: List[TestcaseEntry],
292
+ ) -> List[GenerationTestcaseEntry]:
293
+ # TODO: support subgroups.
294
+ groups = set(entry.group for entry in entries)
295
+ entry_keys = set(entry.key() for entry in entries)
296
+
297
+ res: List[GenerationTestcaseEntry] = []
298
+
299
+ class ExtractGenerationTestcasesVisitor(TestcaseVisitor):
300
+ def should_visit_group(self, group_name: str) -> bool:
301
+ return group_name in groups
302
+
303
+ def visit(self, entry: GenerationTestcaseEntry):
304
+ # TODO: support subgroups.
305
+ if entry.group_entry.key() not in entry_keys:
306
+ return
307
+ res.append(entry)
308
+
309
+ run_testcase_visitor(ExtractGenerationTestcasesVisitor())
310
+ return res
311
+
312
+
313
+ def extract_generation_testcases_from_groups(
314
+ groups: Optional[Set[str]] = None,
315
+ ) -> List[GenerationTestcaseEntry]:
316
+ res: List[GenerationTestcaseEntry] = []
317
+
318
+ class ExtractGenerationTestcasesVisitor(TestcaseGroupVisitor):
319
+ def visit(self, entry: GenerationTestcaseEntry):
320
+ res.append(entry)
321
+
322
+ run_testcase_visitor(ExtractGenerationTestcasesVisitor(groups))
323
+ return res
324
+
325
+
326
+ def extract_generation_testcases_from_patterns(
327
+ patterns: List[TestcasePattern],
328
+ ) -> List[GenerationTestcaseEntry]:
329
+ res: List[GenerationTestcaseEntry] = []
330
+
331
+ class ExtractGenerationTestcasesVisitor(TestcaseVisitor):
332
+ def should_visit_group(self, group_name: str) -> bool:
333
+ return any(pattern.intersecting_group(group_name) for pattern in patterns)
334
+
335
+ def should_visit_subgroup(self, subgroup_path: str) -> bool:
336
+ return any(
337
+ pattern.intersecting_group(subgroup_path) for pattern in patterns
338
+ )
339
+
340
+ def visit(self, entry: GenerationTestcaseEntry):
341
+ if not any(
342
+ pattern.match(entry.group_entry) for pattern in patterns
343
+ ) and not any(pattern.match(entry.subgroup_entry) for pattern in patterns):
344
+ return
345
+ res.append(entry)
346
+
347
+ run_testcase_visitor(ExtractGenerationTestcasesVisitor())
348
+ return res
rbx/box/testcase_utils.py CHANGED
@@ -133,3 +133,13 @@ def get_samples() -> List[Testcase]:
133
133
  )
134
134
  for tc in tcs
135
135
  ]
136
+
137
+
138
+ def fill_output_for_defined_testcase(testcase: Testcase) -> Testcase:
139
+ res = testcase.model_copy()
140
+ if res.outputPath is not None:
141
+ return res
142
+ output_path = res.inputPath.with_suffix('.ans')
143
+ if output_path.is_file():
144
+ res.outputPath = output_path
145
+ return res
rbx/box/testcases/main.py CHANGED
@@ -7,11 +7,13 @@ from rbx import annotations, config, utils
7
7
  from rbx.box import package
8
8
  from rbx.box.generators import (
9
9
  GenerationTestcaseEntry,
10
- extract_generation_testcases,
11
- extract_generation_testcases_from_patterns,
12
10
  generate_outputs_for_testcases,
13
11
  generate_standalone,
14
12
  )
13
+ from rbx.box.testcase_extractors import (
14
+ extract_generation_testcases,
15
+ extract_generation_testcases_from_patterns,
16
+ )
15
17
  from rbx.box.testcase_utils import TestcaseEntry, TestcasePattern
16
18
  from rbx.console import console
17
19
 
rbx/box/validators.py CHANGED
@@ -9,7 +9,10 @@ from rbx import console
9
9
  from rbx.box import package
10
10
  from rbx.box.code import SanitizationLevel, compile_item, run_item
11
11
  from rbx.box.schema import CodeItem, Primitive
12
- from rbx.box.testcase_utils import find_built_testcase_inputs
12
+ from rbx.box.testcase_extractors import (
13
+ GenerationTestcaseEntry,
14
+ extract_generation_testcases_from_groups,
15
+ )
13
16
  from rbx.grading.judge.sandbox import SandboxBase
14
17
  from rbx.grading.steps import (
15
18
  DigestHolder,
@@ -23,6 +26,7 @@ HitBounds = Dict[str, Tuple[bool, bool]]
23
26
 
24
27
 
25
28
  class TestcaseValidationInfo(BaseModel):
29
+ validator: CodeItem
26
30
  group: str
27
31
  path: pathlib.Path
28
32
  ok: bool
@@ -136,7 +140,7 @@ def _validate_testcase(
136
140
  )
137
141
 
138
142
 
139
- def validate_test(
143
+ def _validate_test(
140
144
  testcase: pathlib.Path,
141
145
  validator: CodeItem,
142
146
  validator_digest: str,
@@ -160,8 +164,9 @@ def validate_one_off(
160
164
  validator: CodeItem,
161
165
  validator_digest: str,
162
166
  ) -> TestcaseValidationInfo:
163
- ok, message, _ = validate_test(testcase, validator, validator_digest)
167
+ ok, message, _ = _validate_test(testcase, validator, validator_digest)
164
168
  info = TestcaseValidationInfo(
169
+ validator=validator,
165
170
  group='interactive',
166
171
  path=testcase,
167
172
  ok=ok,
@@ -172,23 +177,29 @@ def validate_one_off(
172
177
 
173
178
 
174
179
  def compile_validators(
180
+ validation_entries: List[GenerationTestcaseEntry],
175
181
  progress: Optional[StatusProgress] = None,
176
182
  ) -> Dict[str, str]:
177
- pkg = package.find_problem_package_or_die()
183
+ validators = []
184
+
185
+ for entry in validation_entries:
186
+ if entry.validator is not None:
187
+ validators.append(entry.validator)
188
+ validators.extend(entry.extra_validators)
178
189
 
179
- group_to_compiled_digest = {}
190
+ validator_to_compiled_digest = {}
180
191
 
181
- for group in pkg.testcases:
182
- validator = group.validator or pkg.validator
183
- if validator is None:
192
+ for validator in validators:
193
+ if str(validator.path) in validator_to_compiled_digest:
184
194
  continue
195
+
185
196
  if progress:
186
- progress.update(
187
- f'Compiling validator for group [item]{group.name}[/item]...'
188
- )
189
- group_to_compiled_digest[group.name] = _compile_validator(validator)
197
+ progress.update(f'Compiling validator [item]{validator.path}[/item]...')
198
+ validator_to_compiled_digest[str(validator.path)] = _compile_validator(
199
+ validator
200
+ )
190
201
 
191
- return group_to_compiled_digest
202
+ return validator_to_compiled_digest
192
203
 
193
204
 
194
205
  def validate_testcases(
@@ -199,38 +210,52 @@ def validate_testcases(
199
210
  if progress is not None:
200
211
  progress.step()
201
212
 
202
- pkg = package.find_problem_package_or_die()
203
-
204
- group_to_compiled_digest = compile_validators(progress)
213
+ validation_entries = extract_generation_testcases_from_groups(groups)
214
+ validator_to_compiled_digest = compile_validators(
215
+ validation_entries, progress=progress
216
+ )
205
217
 
206
218
  validation_info = []
207
219
 
208
- for group in pkg.testcases:
209
- validator = group.validator or pkg.validator
210
- if validator is None:
211
- continue
212
- if group.name not in group_to_compiled_digest:
213
- continue
214
- if groups is not None and group.name not in groups:
220
+ for entry in validation_entries:
221
+ input_path = entry.metadata.copied_to.inputPath
222
+ if not input_path.is_file():
215
223
  continue
216
- compiled_digest = group_to_compiled_digest[group.name]
217
224
 
218
- testcases = find_built_testcase_inputs(group)
225
+ # Main validation.
226
+ if entry.validator is not None:
227
+ compiled_digest = validator_to_compiled_digest[str(entry.validator.path)]
228
+ ok, message, hit_bounds = _validate_test(
229
+ input_path, entry.validator, compiled_digest
230
+ )
231
+ validation_info.append(
232
+ TestcaseValidationInfo(
233
+ validator=entry.validator,
234
+ group=entry.group_entry.group,
235
+ path=input_path,
236
+ ok=ok,
237
+ hit_bounds=hit_bounds,
238
+ message=message,
239
+ )
240
+ )
219
241
 
220
- for testcase in testcases:
221
- ok, message, hit_bounds = validate_test(
222
- testcase, validator, compiled_digest
242
+ for extra_validator in entry.extra_validators:
243
+ compiled_digest = validator_to_compiled_digest[str(extra_validator.path)]
244
+ ok, message, hit_bounds = _validate_test(
245
+ input_path, extra_validator, compiled_digest
223
246
  )
224
247
  validation_info.append(
225
248
  TestcaseValidationInfo(
226
- group=group.name,
227
- path=testcase,
249
+ validator=extra_validator,
250
+ group=entry.group_entry.group,
251
+ path=input_path,
228
252
  ok=ok,
229
253
  hit_bounds=hit_bounds,
230
254
  message=message,
231
255
  )
232
256
  )
233
- step()
257
+
258
+ step()
234
259
 
235
260
  return validation_info
236
261
 
@@ -242,11 +267,14 @@ def has_validation_errors(infos: List[TestcaseValidationInfo]) -> bool:
242
267
  def print_validation_report(infos: List[TestcaseValidationInfo]):
243
268
  console.console.rule('Validation report', style='status')
244
269
  hit_bounds_per_group: Dict[Optional[str], HitBounds] = {}
270
+ any_failure = False
245
271
  for info in infos:
246
272
  if not info.ok:
247
273
  console.console.print(
248
- f'[error]Testcase [item]{info.path}[/item] failed verification:[/error]\n{info.message}'
274
+ f'[error]Testcase [item]{info.path}[/item] failed verification on validator [item]{info.validator.path}[/item]:[/error]'
249
275
  )
276
+ console.console.print(info.message)
277
+ any_failure = True
250
278
  continue
251
279
 
252
280
  if info.group not in hit_bounds_per_group:
@@ -278,7 +306,7 @@ def print_validation_report(infos: List[TestcaseValidationInfo]):
278
306
  # If there's only the samples group, do not check for hit bounds.
279
307
  hit_bounds_per_group = {}
280
308
 
281
- if not hit_bounds_per_group:
309
+ if not hit_bounds_per_group and not any_failure:
282
310
  console.console.print('[info]No validation issues found.[/info]')
283
311
  return
284
312