rbx.cp 0.5.72__py3-none-any.whl → 0.6.0__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.
Files changed (71) hide show
  1. rbx/annotations.py +21 -1
  2. rbx/box/cli.py +24 -8
  3. rbx/box/code.py +140 -3
  4. rbx/box/contest/build_contest_statements.py +44 -34
  5. rbx/box/contest/contest_utils.py +25 -0
  6. rbx/box/contest/main.py +24 -0
  7. rbx/box/contest/schema.py +52 -8
  8. rbx/box/contest/statements.py +53 -25
  9. rbx/box/download.py +19 -1
  10. rbx/box/fields.py +35 -0
  11. rbx/box/lang.py +27 -0
  12. rbx/box/package.py +1 -1
  13. rbx/box/packaging/boca/packager.py +48 -5
  14. rbx/box/packaging/contest_main.py +13 -0
  15. rbx/box/packaging/main.py +13 -2
  16. rbx/box/packaging/packager.py +4 -4
  17. rbx/box/packaging/pkg/packager.py +142 -0
  18. rbx/box/packaging/polygon/packager.py +2 -24
  19. rbx/box/packaging/polygon/upload.py +35 -17
  20. rbx/box/remote.py +2 -2
  21. rbx/box/schema.py +68 -18
  22. rbx/box/solutions.py +6 -1
  23. rbx/box/statements/build_statements.py +44 -27
  24. rbx/box/statements/builders.py +18 -10
  25. rbx/box/statements/expander.py +49 -0
  26. rbx/box/statements/latex_jinja.py +61 -4
  27. rbx/box/statements/schema.py +33 -9
  28. rbx/box/testcase_utils.py +19 -47
  29. rbx/box/tooling/__init__.py +0 -0
  30. rbx/box/tooling/boca/__init__.py +0 -0
  31. rbx/box/tooling/boca/main.py +13 -0
  32. rbx/box/tooling/boca/scrape.py +34 -0
  33. rbx/box/{packaging/boca/upload.py → tooling/boca/scraper.py} +77 -8
  34. rbx/box/tooling/main.py +8 -0
  35. rbx/box/ui/screens/run_explorer.py +1 -1
  36. rbx/box/ui/widgets/interaction_box.py +19 -1
  37. rbx/grading/caching.py +18 -2
  38. rbx/grading/judge/sandbox.py +48 -5
  39. rbx/grading/judge/sandboxes/isolate.py +1 -0
  40. rbx/grading/judge/sandboxes/stupid_sandbox.py +11 -5
  41. rbx/grading/judge/sandboxes/timeit.py +36 -15
  42. rbx/grading/processing_context.py +62 -78
  43. rbx/grading/steps.py +91 -40
  44. rbx/resources/packagers/boca/checker.sh +4 -1
  45. rbx/resources/packagers/boca/compile/c +2 -6
  46. rbx/resources/packagers/boca/compile/cc +2 -6
  47. rbx/resources/packagers/boca/compile/cpp +2 -6
  48. rbx/resources/packagers/boca/compile/java +1 -6
  49. rbx/resources/packagers/boca/compile/kt +24 -28
  50. rbx/resources/packagers/boca/compile/py2 +2 -6
  51. rbx/resources/packagers/boca/compile/py3 +2 -6
  52. rbx/resources/packagers/boca/interactive/c +15 -62
  53. rbx/resources/packagers/boca/interactive/cc +15 -62
  54. rbx/resources/packagers/boca/interactive/cpp +15 -61
  55. rbx/resources/packagers/boca/interactive/java +15 -67
  56. rbx/resources/packagers/boca/interactive/kt +15 -67
  57. rbx/resources/packagers/boca/interactive/py2 +15 -67
  58. rbx/resources/packagers/boca/interactive/py3 +15 -65
  59. rbx/resources/packagers/boca/interactor_compile.sh +5 -2
  60. rbx/resources/packagers/boca/interactor_run.sh +174 -0
  61. rbx/resources/packagers/boca/safeexec.c +530 -0
  62. rbx/resources/packagers/boca/safeexec_compile.sh +49 -0
  63. rbx/resources/presets/default/contest/contest.rbx.yml +9 -8
  64. rbx/resources/presets/default/problem/problem.rbx.yml +27 -26
  65. rbx/resources/templates/rbx.h +2 -3
  66. {rbx_cp-0.5.72.dist-info → rbx_cp-0.6.0.dist-info}/METADATA +2 -1
  67. {rbx_cp-0.5.72.dist-info → rbx_cp-0.6.0.dist-info}/RECORD +70 -59
  68. rbx/resources/packagers/boca/compile/pas +0 -172
  69. {rbx_cp-0.5.72.dist-info → rbx_cp-0.6.0.dist-info}/LICENSE +0 -0
  70. {rbx_cp-0.5.72.dist-info → rbx_cp-0.6.0.dist-info}/WHEEL +0 -0
  71. {rbx_cp-0.5.72.dist-info → rbx_cp-0.6.0.dist-info}/entry_points.txt +0 -0
rbx/annotations.py CHANGED
@@ -1,7 +1,7 @@
1
1
  import importlib.resources
2
2
  import pathlib
3
3
  import re
4
- from typing import List, Optional
4
+ from typing import Any, Dict, List, Optional
5
5
 
6
6
  import typer
7
7
  import typer.core
@@ -112,6 +112,26 @@ Checker = Annotated[
112
112
  ]
113
113
 
114
114
 
115
+ def parse_dictionary(value: Optional[str]) -> Dict[str, Any]:
116
+ if value is None:
117
+ return {}
118
+ res = {}
119
+ for item in value.split(','):
120
+ key, value = item.split('=', 1)
121
+ res[key] = value
122
+ return res
123
+
124
+
125
+ def parse_dictionary_items(items: Optional[List[str]]) -> Dict[str, Any]:
126
+ if items is None:
127
+ return {}
128
+ res = {}
129
+ for item in items:
130
+ key, value = item.split('=', 1)
131
+ res[key] = value
132
+ return res
133
+
134
+
115
135
  class AliasGroup(typer.core.TyperGroup):
116
136
  _CMD_SPLIT_P = re.compile(r', ?')
117
137
 
rbx/box/cli.py CHANGED
@@ -42,6 +42,7 @@ from rbx.box.solutions import (
42
42
  from rbx.box.statements import build_statements
43
43
  from rbx.box.testcase_utils import TestcaseEntry
44
44
  from rbx.box.testcases import main as testcases
45
+ from rbx.box.tooling import main as tooling
45
46
 
46
47
  app = typer.Typer(no_args_is_help=True, cls=annotations.AliasGroup)
47
48
  app.add_typer(
@@ -60,7 +61,7 @@ app.add_typer(
60
61
  )
61
62
  app.add_typer(
62
63
  download.app,
63
- name='download',
64
+ name='download, down',
64
65
  cls=annotations.AliasGroup,
65
66
  help='Download an asset from supported repositories (sub-command).',
66
67
  rich_help_panel='Management',
@@ -93,6 +94,13 @@ app.add_typer(
93
94
  help='Manage testcases (sub-command).',
94
95
  rich_help_panel='Management',
95
96
  )
97
+ app.add_typer(
98
+ tooling.app,
99
+ name='tool, tooling',
100
+ cls=annotations.AliasGroup,
101
+ help='Manage tooling (sub-command).',
102
+ rich_help_panel='Misc',
103
+ )
96
104
 
97
105
 
98
106
  @app.callback()
@@ -104,12 +112,11 @@ def main(
104
112
  help='Whether to compile and run testlib components with sanitizers enabled. '
105
113
  'If you want to run the solutions with sanitizers enabled, use the "-s" flag in the corresponding run command.',
106
114
  ),
107
- debug_logs: bool = typer.Option(
108
- False,
109
- '--debug-logs',
110
- '--debug',
111
- '-d',
112
- help='Whether to save extra debug logs along with the evaluation results.',
115
+ capture: bool = typer.Option(
116
+ True,
117
+ '--nocapture',
118
+ flag_value=False,
119
+ help='Whether to save extra logs and outputs from interactive solutions.',
113
120
  ),
114
121
  ):
115
122
  if cd.is_problem_package() and not package.is_cache_valid():
@@ -125,7 +132,7 @@ def main(
125
132
  '[warning]Sanitizers are running just for testlib components.\n'
126
133
  'If you want to run the solutions with sanitizers enabled, use the [item]-s[/item] flag in the corresponding run command.[/warning]'
127
134
  )
128
- state.STATE.debug_logs = debug_logs
135
+ state.STATE.debug_logs = capture
129
136
 
130
137
 
131
138
  @app.command('ui', hidden=True)
@@ -136,6 +143,15 @@ def ui():
136
143
  ui_pkg.start()
137
144
 
138
145
 
146
+ @app.command(
147
+ 'on',
148
+ help='Run a command in the context of a problem (or a set of problems) of a contest.',
149
+ context_settings={'allow_extra_args': True, 'ignore_unknown_options': True},
150
+ )
151
+ def on(ctx: typer.Context, problems: str) -> None:
152
+ contest.on(ctx, problems)
153
+
154
+
139
155
  @app.command('diff', hidden=True)
140
156
  def diff(path1: pathlib.Path, path2: pathlib.Path):
141
157
  from rbx.box.ui import main as ui_pkg
rbx/box/code.py CHANGED
@@ -14,6 +14,7 @@ import typer
14
14
  from rbx import console
15
15
  from rbx.box import download, package, setter_config, state
16
16
  from rbx.box.environment import (
17
+ CompilationConfig,
17
18
  ExecutionConfig,
18
19
  FileMapping,
19
20
  get_compilation_config,
@@ -29,7 +30,7 @@ from rbx.box.formatting import get_formatted_memory
29
30
  from rbx.box.sanitizers import warning_stack
30
31
  from rbx.box.schema import CodeItem
31
32
  from rbx.grading import steps, steps_with_caching
32
- from rbx.grading.judge.sandbox import SandboxParams
33
+ from rbx.grading.judge.sandbox import SandboxBase, SandboxParams
33
34
  from rbx.grading.steps import (
34
35
  DigestHolder,
35
36
  DigestOrDest,
@@ -39,7 +40,10 @@ from rbx.grading.steps import (
39
40
  GradingFileOutput,
40
41
  RunLog,
41
42
  RunLogMetadata,
43
+ get_exe_from_command,
44
+ is_cpp_command,
42
45
  is_cxx_command,
46
+ maybe_get_bits_stdcpp_for_commands,
43
47
  )
44
48
 
45
49
 
@@ -391,12 +395,109 @@ def _prepare_run(
391
395
  )
392
396
 
393
397
 
398
+ def _should_precompile(commands: List[str]) -> bool:
399
+ return any(is_cpp_command(command) for command in commands)
400
+
401
+
402
+ def _precompile_header(
403
+ compilation_options: CompilationConfig,
404
+ sanitized: SanitizationLevel,
405
+ sandbox: SandboxBase,
406
+ sandbox_params: SandboxParams,
407
+ artifacts: GradingArtifacts,
408
+ input_artifact: GradingFileInput,
409
+ force_warnings: bool = False,
410
+ verbose: bool = False,
411
+ ) -> GradingFileInput:
412
+ """
413
+ Precompile a header file (.h).
414
+
415
+ Assumes input artifact is a header file (.h) and compilation commands are C++.
416
+ """
417
+ assert compilation_options.commands is not None
418
+
419
+ dependency_cache = package.get_dependency_cache()
420
+
421
+ # TODO: deduplicate code with compile_item.
422
+ commands = get_mapped_commands(
423
+ compilation_options.commands,
424
+ FileMapping(
425
+ compilable='precompilable.h',
426
+ executable='precompilable.h.gch',
427
+ ),
428
+ )
429
+ commands = add_warning_flags(commands, force_warnings)
430
+ commands = substitute_commands(commands, sanitized=sanitized.should_sanitize())
431
+
432
+ if sanitized.should_sanitize():
433
+ commands = add_sanitizer_flags(commands)
434
+
435
+ precompilation_artifacts = GradingArtifacts()
436
+
437
+ # Keep only header files.
438
+ precompilation_artifacts.inputs = [
439
+ input
440
+ for input in artifacts.inputs
441
+ if input.src is not None and input.src.suffix == '.h'
442
+ ]
443
+ precompilation_artifacts.inputs.append(
444
+ GradingFileInput(
445
+ src=input_artifact.src,
446
+ dest=PosixPath('precompilable.h'),
447
+ )
448
+ )
449
+
450
+ # Pull only the precompiled header file.
451
+ precompiled_digest = DigestHolder()
452
+ precompilation_artifacts.outputs.append(
453
+ GradingFileOutput(
454
+ src=PosixPath('precompilable.h.gch'),
455
+ digest=precompiled_digest,
456
+ executable=True,
457
+ )
458
+ )
459
+
460
+ if not steps_with_caching.compile(
461
+ commands,
462
+ params=sandbox_params,
463
+ artifacts=precompilation_artifacts,
464
+ sandbox=sandbox,
465
+ dependency_cache=dependency_cache,
466
+ ):
467
+ console.console.print(
468
+ f'[error]Failed to precompile header file: [item]{input_artifact.src}[/item][/error]'
469
+ )
470
+ raise typer.Exit(1)
471
+
472
+ if verbose:
473
+ console.console.print(
474
+ f'[status]Precompiled header file: [item]{input_artifact.src}[/item]'
475
+ )
476
+
477
+ if (
478
+ precompilation_artifacts.logs is not None
479
+ and precompilation_artifacts.logs.preprocess is not None
480
+ ):
481
+ for log in precompilation_artifacts.logs.preprocess:
482
+ console.console.print(f'[status]Command:[/status] {log.get_command()}')
483
+ console.console.print(f'[status]Summary:[/status] {log.get_summary()}')
484
+
485
+ assert precompiled_digest.value is not None
486
+
487
+ return GradingFileInput(
488
+ digest=precompiled_digest,
489
+ dest=input_artifact.dest.with_suffix('.h.gch'),
490
+ executable=True,
491
+ )
492
+
493
+
394
494
  # Compile code item and return its digest in the storage.
395
495
  def compile_item(
396
496
  code: CodeItem,
397
497
  sanitized: SanitizationLevel = SanitizationLevel.PREFER,
398
498
  force_warnings: bool = False,
399
499
  verbose: bool = False,
500
+ precompile: bool = True,
400
501
  ) -> str:
401
502
  _check_stack_limit()
402
503
 
@@ -461,6 +562,41 @@ def compile_item(
461
562
  for input in artifacts.inputs:
462
563
  _ignore_warning_in_cxx_input(input)
463
564
 
565
+ # Add system bits/stdc++.h to the compilation.
566
+ bits_artifact = maybe_get_bits_stdcpp_for_commands(commands)
567
+ if bits_artifact is not None:
568
+ artifacts.inputs.append(bits_artifact)
569
+ commands = [
570
+ command + ' -I.'
571
+ for command in commands
572
+ if is_cxx_command(get_exe_from_command(command))
573
+ ]
574
+
575
+ # Precompile C++ header files.
576
+ if precompile and _should_precompile(commands):
577
+ precompilation_inputs = []
578
+ for input in artifacts.inputs:
579
+ if (
580
+ input.src is not None
581
+ and input.src.suffix == '.h'
582
+ and input.dest.suffix == '.h'
583
+ ):
584
+ precompilation_inputs.append(
585
+ _precompile_header(
586
+ compilation_options,
587
+ sanitized,
588
+ sandbox,
589
+ sandbox_params,
590
+ artifacts,
591
+ input,
592
+ force_warnings,
593
+ verbose=False,
594
+ )
595
+ )
596
+ if precompilation_inputs:
597
+ artifacts.inputs.extend(precompilation_inputs)
598
+
599
+ # Compile the code.
464
600
  if not steps_with_caching.compile(
465
601
  commands,
466
602
  params=sandbox_params,
@@ -473,6 +609,7 @@ def compile_item(
473
609
  assert compiled_digest.value is not None
474
610
 
475
611
  if verbose and artifacts.logs is not None and artifacts.logs.preprocess is not None:
612
+ console.console.print(f'[status]Compiled item: [item]{code.path}[/item]')
476
613
  for log in artifacts.logs.preprocess:
477
614
  console.console.print(f'[status]Command:[/status] {log.get_command()}')
478
615
  console.console.print(f'[status]Summary:[/status] {log.get_summary()}')
@@ -589,8 +726,8 @@ async def run_communication(
589
726
  interactor_prepared.metadata.retryIndex = retry_index
590
727
  solution_prepared.metadata.retryIndex = retry_index
591
728
 
592
- interactor_prefix = 'INTERACTOR:'
593
- solution_prefix = 'SOLUTION:'
729
+ interactor_prefix = '<'
730
+ solution_prefix = '>'
594
731
 
595
732
  if merged_capture is not None:
596
733
  package.get_merged_capture_path().write_text(
@@ -2,7 +2,7 @@ import dataclasses
2
2
  import pathlib
3
3
  import tempfile
4
4
  import typing
5
- from typing import List, Optional, Tuple
5
+ from typing import Any, Dict, List, Optional, Tuple
6
6
 
7
7
  import typer
8
8
 
@@ -85,36 +85,41 @@ def get_statement_builder_contest(
85
85
 
86
86
  def get_problems_for_statement(
87
87
  contest: Contest,
88
- language: str,
88
+ contest_statement: ContestStatement,
89
89
  requires_matching_statement: bool = True,
90
90
  ) -> List[ExtractedProblem]:
91
91
  pkgs = get_problems(contest)
92
- if not pkgs:
92
+ if not pkgs and not requires_matching_statement:
93
93
  console.console.print(
94
94
  '[error]No problems found in the contest, cannot infer statement type.[/error]'
95
95
  )
96
96
  raise typer.Exit(1)
97
97
 
98
+ def matches(statement: Statement) -> bool:
99
+ if not requires_matching_statement:
100
+ return True
101
+ if contest_statement.match is None:
102
+ return statement.language == contest_statement.language
103
+ return statement.name == contest_statement.match
104
+
98
105
  res = []
99
106
  for pkg, problem in zip(pkgs, contest.problems):
100
- found = False
101
- for statement in pkg.statements:
102
- if statement.language == language or not requires_matching_statement:
103
- found = True
104
- res.append(
105
- ExtractedProblem(
106
- package=pkg,
107
- statement=statement,
108
- problem=problem,
109
- samples=_get_samples(problem),
110
- )
111
- )
112
- break
113
- if not found:
107
+ matching_statements = [
108
+ statement for statement in pkg.expanded_statements if matches(statement)
109
+ ]
110
+ if not matching_statements:
114
111
  console.console.print(
115
- f'[error]No statement found for language {language} in problem {problem.short_name}[/error]'
112
+ f'[error]No statement found for language {contest_statement.language} in problem {problem.short_name}[/error]'
116
113
  )
117
114
  raise typer.Exit(1)
115
+ res.append(
116
+ ExtractedProblem(
117
+ package=pkg,
118
+ statement=matching_statements[0],
119
+ problem=problem,
120
+ samples=_get_samples(problem),
121
+ )
122
+ )
118
123
 
119
124
  return res
120
125
 
@@ -146,14 +151,17 @@ def _build_problem_statements(
146
151
  root: pathlib.Path,
147
152
  output_type: StatementType,
148
153
  use_samples: bool = True,
149
- is_editorial: bool = False,
154
+ custom_vars: Optional[Dict[str, Any]] = None,
150
155
  ) -> List[ExtractedProblem]:
151
156
  console.console.print('Building problem-level statements...')
152
- extracted_problems = get_problems_for_statement(contest, statement.language)
157
+ extracted_problems = get_problems_for_statement(contest, statement)
153
158
  res = []
154
159
  contest_cwd_absolute = pathlib.Path().resolve()
155
160
  contest_assets = get_relative_assets(statement.path, statement.assets)
156
161
 
162
+ extra_vars = dict(statement.override.vars if statement.override is not None else {})
163
+ extra_vars.update(custom_vars or {})
164
+
157
165
  for extracted_problem in extracted_problems:
158
166
  console.console.print(
159
167
  f'Building statement for problem {extracted_problem.problem.short_name}...'
@@ -174,7 +182,8 @@ def _build_problem_statements(
174
182
  overridden_assets=contest_assets, # overridden assets
175
183
  overridden_params_root=contest_cwd_absolute,
176
184
  use_samples=use_samples,
177
- is_editorial=is_editorial,
185
+ # Use custom var overriding and problem-level overriding.
186
+ custom_vars=extra_vars,
178
187
  )
179
188
  dest_dir = root / '.problems' / extracted_problem.problem.short_name
180
189
  dest_path = dest_dir / f'statement{output_type.get_file_suffix()}'
@@ -201,7 +210,7 @@ def build_contest_only(
201
210
  input: bytes,
202
211
  input_type: StatementType,
203
212
  output_type: Optional[StatementType] = None,
204
- is_editorial: bool = False,
213
+ custom_vars: Optional[Dict[str, Any]] = None,
205
214
  ) -> Tuple[bytes, StatementType]:
206
215
  console.console.print('Building contest-level statement.')
207
216
  bdrs = get_builders(
@@ -224,10 +233,11 @@ def build_contest_only(
224
233
  output = bdr.build(
225
234
  input=last_content,
226
235
  context=StatementBuilderContext(
236
+ lang=statement.language,
227
237
  languages=get_environment_languages_for_statement(),
228
238
  params=params,
229
239
  root=pathlib.Path(td),
230
- editorial=is_editorial,
240
+ custom_vars=custom_vars,
231
241
  vars={**contest.expanded_vars, **statement.expanded_vars},
232
242
  ),
233
243
  item=get_statement_builder_contest(statement, extracted_problems),
@@ -245,7 +255,7 @@ def build_statement_rooted(
245
255
  root: pathlib.Path,
246
256
  output_type: Optional[StatementType] = None,
247
257
  use_samples: bool = True,
248
- is_editorial: bool = False,
258
+ custom_vars: Optional[Dict[str, Any]] = None,
249
259
  ) -> Tuple[bytes, StatementType]:
250
260
  # Validate.
251
261
  if not statement.path.is_file():
@@ -257,7 +267,7 @@ def build_statement_rooted(
257
267
  if statement.joiner is None:
258
268
  joiner = None
259
269
  extracted_problems = get_problems_for_statement(
260
- contest, statement.language, requires_matching_statement=False
270
+ contest, statement, requires_matching_statement=False
261
271
  )
262
272
  else:
263
273
  # Build problem-level statements.
@@ -268,7 +278,7 @@ def build_statement_rooted(
268
278
  root,
269
279
  output_type=joiner.joined_type(),
270
280
  use_samples=use_samples,
271
- is_editorial=is_editorial,
281
+ custom_vars=custom_vars,
272
282
  )
273
283
 
274
284
  # Build contest-level statement into joiner input type.
@@ -279,7 +289,7 @@ def build_statement_rooted(
279
289
  statement.path.read_bytes(),
280
290
  statement.type,
281
291
  output_type=joiner.joined_type() if joiner is not None else output_type,
282
- is_editorial=is_editorial,
292
+ custom_vars=custom_vars,
283
293
  )
284
294
 
285
295
  if joiner is None:
@@ -313,7 +323,7 @@ def build_statement_rooted(
313
323
  last_content,
314
324
  last_output,
315
325
  output_type=output_type,
316
- is_editorial=is_editorial,
326
+ custom_vars=custom_vars,
317
327
  )
318
328
 
319
329
  return last_content, last_output
@@ -324,7 +334,7 @@ def build_statement(
324
334
  contest: Contest,
325
335
  output_type: Optional[StatementType] = None,
326
336
  use_samples: bool = True,
327
- is_editorial: bool = False,
337
+ custom_vars: Optional[Dict[str, Any]] = None,
328
338
  ) -> pathlib.Path:
329
339
  with tempfile.TemporaryDirectory() as td:
330
340
  root = pathlib.Path(td)
@@ -334,17 +344,17 @@ def build_statement(
334
344
  root,
335
345
  output_type=output_type,
336
346
  use_samples=use_samples,
337
- is_editorial=is_editorial,
347
+ custom_vars=custom_vars,
338
348
  )
339
349
 
340
- statement_path = pathlib.Path(
341
- f'build/{statement.path.stem}{last_output.get_file_suffix()}'
350
+ statement_path = (pathlib.Path('build') / statement.name).with_suffix(
351
+ last_output.get_file_suffix()
342
352
  )
343
353
  statement_path.parent.mkdir(parents=True, exist_ok=True)
344
354
  statement_path.write_bytes(typing.cast(bytes, last_content))
345
355
  console.console.print(
346
- f'Statement built successfully for language '
356
+ f'[success]Statement [item]{statement.name}[/item] built successfully for language '
347
357
  f'[item]{statement.language}[/item] at '
348
- f'{href(statement_path)}'
358
+ f'{href(statement_path)}[/success]'
349
359
  )
350
360
  return statement_path
@@ -1,5 +1,30 @@
1
+ from typing import List
2
+
1
3
  from rbx.box import environment, package
2
4
  from rbx.box.contest import contest_package
5
+ from rbx.box.contest.schema import ContestProblem
6
+
7
+
8
+ def match_problem(problems: str, contest_problem: ContestProblem) -> bool:
9
+ short_name = contest_problem.short_name.lower()
10
+ problems = problems.lower()
11
+ if problems == '*':
12
+ return True
13
+ if '-' in problems:
14
+ start, end = problems.split('-')
15
+ return start <= short_name <= end
16
+ problem_set = set(p.strip().lower() for p in problems.split(','))
17
+ return short_name in problem_set
18
+
19
+
20
+ def get_problems_of_interest(problems: str) -> List[ContestProblem]:
21
+ contest = contest_package.find_contest_package_or_die()
22
+ problems_of_interest = []
23
+
24
+ for p in contest.problems:
25
+ if match_problem(problems, p):
26
+ problems_of_interest.append(p)
27
+ return problems_of_interest
3
28
 
4
29
 
5
30
  def clear_all_caches():
rbx/box/contest/main.py CHANGED
@@ -232,3 +232,27 @@ def each(ctx: typer.Context) -> None:
232
232
  console.console.print(
233
233
  '[error]One of the commands above failed. Check the output![/error]'
234
234
  )
235
+
236
+
237
+ @app.command(
238
+ 'on',
239
+ help='Run a command in the problem (or in a set of problems) of a context.',
240
+ context_settings={'allow_extra_args': True, 'ignore_unknown_options': True},
241
+ )
242
+ @within_contest
243
+ def on(ctx: typer.Context, problems: str) -> None:
244
+ command = ' '.join(['rbx'] + ctx.args)
245
+ problems_of_interest = contest_utils.get_problems_of_interest(problems)
246
+
247
+ if not problems_of_interest:
248
+ console.console.print(
249
+ f'[error]No problems found in contest matching [item]{problems}[/item].[/error]'
250
+ )
251
+ raise typer.Exit(1)
252
+
253
+ for p in problems_of_interest:
254
+ console.console.print(
255
+ f'[status]Running [item]{command}[/item] for [item]{p.short_name}[/item]...[/status]'
256
+ )
257
+ subprocess.call(command, cwd=p.get_path(), shell=True)
258
+ console.console.print()
rbx/box/contest/schema.py CHANGED
@@ -1,12 +1,15 @@
1
1
  import pathlib
2
- from typing import Dict, List, Optional
2
+ from typing import Annotated, Dict, List, Optional
3
3
 
4
- from pydantic import BaseModel, ConfigDict, Field, model_validator
4
+ from pydantic import AfterValidator, BaseModel, ConfigDict, Field, model_validator
5
5
 
6
- from rbx.box.schema import NameField, Primitive, expand_var
6
+ from rbx.box.fields import FNameField, NameField
7
+ from rbx.box.schema import Primitive, expand_var
8
+ from rbx.box.statements.expander import expand_statements
7
9
  from rbx.box.statements.schema import (
8
10
  ConversionStep,
9
11
  Joiner,
12
+ StatementLanguage,
10
13
  StatementType,
11
14
  )
12
15
 
@@ -15,6 +18,13 @@ def ShortNameField(**kwargs):
15
18
  return Field(pattern=r'^[A-Z]+[0-9]*$', min_length=1, max_length=4, **kwargs)
16
19
 
17
20
 
21
+ def is_unique_by_name(statements: List['ContestStatement']) -> List['ContestStatement']:
22
+ names = {st.name for st in statements}
23
+ if len(names) != len(statements):
24
+ raise ValueError('Statement names must be unique.')
25
+ return statements
26
+
27
+
18
28
  class ProblemStatementOverride(BaseModel):
19
29
  model_config = ConfigDict(extra='forbid')
20
30
 
@@ -29,13 +39,26 @@ configure them in case they are applied.
29
39
  """,
30
40
  )
31
41
 
42
+ vars: Dict[str, Primitive] = Field(
43
+ default={},
44
+ description='Variables to be merged into the problem statement vars.',
45
+ )
46
+
32
47
 
33
48
  class ContestStatement(BaseModel):
34
49
  model_config = ConfigDict(extra='forbid')
35
50
 
36
- language: str = Field(default='en', description='Language code for this statement.')
51
+ name: str = FNameField(description='Name of this statement.')
52
+
53
+ extends: Optional[str] = FNameField(
54
+ default=None, description='Name of the statement to inherit from.'
55
+ )
56
+
57
+ language: StatementLanguage = Field(
58
+ default='en', description='Language code for this statement (ISO 639-1).'
59
+ )
37
60
 
38
- title: str = Field(description='Title of the contest in this language.')
61
+ title: str = Field(default='', description='Title of the contest in this language.')
39
62
 
40
63
  location: Optional[str] = Field(
41
64
  default=None, description='Location of the contest in this language.'
@@ -45,9 +68,14 @@ class ContestStatement(BaseModel):
45
68
  default=None, description='Date of the contest in this language.'
46
69
  )
47
70
 
48
- path: pathlib.Path = Field(description='Path to the input statement file.')
71
+ path: pathlib.Path = Field(
72
+ default_factory=pathlib.Path,
73
+ description='Path to the input statement file.',
74
+ )
49
75
 
50
- type: StatementType = Field(description='Type of the input statement file.')
76
+ type: StatementType = Field(
77
+ default=StatementType.rbxTeX, description='Type of the input statement file.'
78
+ )
51
79
 
52
80
  joiner: Optional[Joiner] = Field(
53
81
  default=None,
@@ -95,6 +123,15 @@ Can be glob pattern as well, such as `imgs/*.png`.
95
123
  default=None, description='Override configuration for problem statements.'
96
124
  )
97
125
 
126
+ match: Optional[str] = FNameField(
127
+ default=None,
128
+ description="""
129
+ Name of the problem-level statement to match this statement against.
130
+
131
+ If not specified, will match against the first statement of the same language.
132
+ """,
133
+ )
134
+
98
135
  # Vars to be re-used in the statement.
99
136
  # - It will be available as \VAR{vars} variable in the contest-level box statement.
100
137
  vars: Dict[str, Primitive] = Field(
@@ -188,7 +225,10 @@ class Contest(BaseModel):
188
225
  default=[], description='List of problems in this contest.'
189
226
  )
190
227
 
191
- statements: List[ContestStatement] = Field(
228
+ statements: Annotated[
229
+ List[ContestStatement],
230
+ AfterValidator(is_unique_by_name),
231
+ ] = Field(
192
232
  default=None,
193
233
  description='Configure statements in this contest, per language.',
194
234
  )
@@ -199,6 +239,10 @@ class Contest(BaseModel):
199
239
  default={}, description='Variables to be re-used across the package.'
200
240
  )
201
241
 
242
+ @property
243
+ def expanded_statements(self) -> List[ContestStatement]:
244
+ return expand_statements(self.statements)
245
+
202
246
  @property
203
247
  def expanded_vars(self) -> Dict[str, Primitive]:
204
248
  return {key: expand_var(value) for key, value in self.vars.items()}