rbx.cp 0.5.73__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 (67) hide show
  1. rbx/annotations.py +21 -1
  2. rbx/box/cli.py +10 -1
  3. rbx/box/code.py +140 -3
  4. rbx/box/contest/build_contest_statements.py +44 -34
  5. rbx/box/contest/schema.py +52 -8
  6. rbx/box/contest/statements.py +53 -25
  7. rbx/box/fields.py +35 -0
  8. rbx/box/lang.py +27 -0
  9. rbx/box/package.py +1 -1
  10. rbx/box/packaging/boca/packager.py +48 -5
  11. rbx/box/packaging/contest_main.py +13 -0
  12. rbx/box/packaging/main.py +13 -2
  13. rbx/box/packaging/packager.py +4 -4
  14. rbx/box/packaging/pkg/packager.py +142 -0
  15. rbx/box/packaging/polygon/packager.py +2 -24
  16. rbx/box/packaging/polygon/upload.py +35 -17
  17. rbx/box/remote.py +2 -2
  18. rbx/box/schema.py +68 -18
  19. rbx/box/solutions.py +6 -1
  20. rbx/box/statements/build_statements.py +44 -27
  21. rbx/box/statements/builders.py +18 -10
  22. rbx/box/statements/expander.py +49 -0
  23. rbx/box/statements/latex_jinja.py +61 -4
  24. rbx/box/statements/schema.py +33 -9
  25. rbx/box/testcase_utils.py +19 -47
  26. rbx/box/tooling/__init__.py +0 -0
  27. rbx/box/tooling/boca/__init__.py +0 -0
  28. rbx/box/tooling/boca/main.py +13 -0
  29. rbx/box/tooling/boca/scrape.py +34 -0
  30. rbx/box/{packaging/boca/upload.py → tooling/boca/scraper.py} +77 -8
  31. rbx/box/tooling/main.py +8 -0
  32. rbx/box/ui/widgets/interaction_box.py +19 -1
  33. rbx/grading/caching.py +18 -2
  34. rbx/grading/judge/sandbox.py +48 -5
  35. rbx/grading/judge/sandboxes/isolate.py +1 -0
  36. rbx/grading/judge/sandboxes/stupid_sandbox.py +11 -5
  37. rbx/grading/judge/sandboxes/timeit.py +36 -15
  38. rbx/grading/processing_context.py +62 -78
  39. rbx/grading/steps.py +91 -40
  40. rbx/resources/packagers/boca/checker.sh +4 -1
  41. rbx/resources/packagers/boca/compile/c +2 -6
  42. rbx/resources/packagers/boca/compile/cc +2 -6
  43. rbx/resources/packagers/boca/compile/cpp +2 -6
  44. rbx/resources/packagers/boca/compile/java +1 -6
  45. rbx/resources/packagers/boca/compile/kt +24 -28
  46. rbx/resources/packagers/boca/compile/py2 +2 -6
  47. rbx/resources/packagers/boca/compile/py3 +2 -6
  48. rbx/resources/packagers/boca/interactive/c +15 -83
  49. rbx/resources/packagers/boca/interactive/cc +15 -83
  50. rbx/resources/packagers/boca/interactive/cpp +15 -83
  51. rbx/resources/packagers/boca/interactive/java +15 -88
  52. rbx/resources/packagers/boca/interactive/kt +15 -88
  53. rbx/resources/packagers/boca/interactive/py2 +15 -88
  54. rbx/resources/packagers/boca/interactive/py3 +15 -88
  55. rbx/resources/packagers/boca/interactor_compile.sh +5 -2
  56. rbx/resources/packagers/boca/interactor_run.sh +174 -0
  57. rbx/resources/packagers/boca/safeexec.c +530 -0
  58. rbx/resources/packagers/boca/safeexec_compile.sh +49 -0
  59. rbx/resources/presets/default/contest/contest.rbx.yml +9 -8
  60. rbx/resources/presets/default/problem/problem.rbx.yml +27 -26
  61. rbx/resources/templates/rbx.h +2 -3
  62. {rbx_cp-0.5.73.dist-info → rbx_cp-0.6.0.dist-info}/METADATA +2 -1
  63. {rbx_cp-0.5.73.dist-info → rbx_cp-0.6.0.dist-info}/RECORD +66 -55
  64. rbx/resources/packagers/boca/compile/pas +0 -172
  65. {rbx_cp-0.5.73.dist-info → rbx_cp-0.6.0.dist-info}/LICENSE +0 -0
  66. {rbx_cp-0.5.73.dist-info → rbx_cp-0.6.0.dist-info}/WHEEL +0 -0
  67. {rbx_cp-0.5.73.dist-info → rbx_cp-0.6.0.dist-info}/entry_points.txt +0 -0
rbx/box/schema.py CHANGED
@@ -2,30 +2,21 @@ from __future__ import annotations
2
2
 
3
3
  import os
4
4
  import pathlib
5
- from typing import Dict, List, Optional, Union
5
+ import re
6
+ from typing import Annotated, Any, Dict, List, Optional, Union
6
7
 
7
- from pydantic import BaseModel, ConfigDict, Field, model_validator
8
+ from pydantic import AfterValidator, BaseModel, ConfigDict, Field, model_validator
8
9
  from pydantic_core import PydanticCustomError
9
10
 
10
11
  from rbx.autoenum import AutoEnum, alias
12
+ from rbx.box.fields import FNameField, NameField
13
+ from rbx.box.statements.expander import expand_statements
11
14
  from rbx.box.statements.schema import Statement
12
15
  from rbx.grading.steps import Outcome
13
16
 
14
17
  Primitive = Union[str, int, float, bool]
15
18
 
16
19
 
17
- def NameField(**kwargs):
18
- return Field(
19
- pattern=r'^[a-zA-Z0-9][a-zA-Z0-9\-_]*$', min_length=3, max_length=32, **kwargs
20
- )
21
-
22
-
23
- def FNameField(**kwargs):
24
- return Field(
25
- pattern=r'^[a-zA-Z0-9][a-zA-Z0-9\-_]*$', min_length=3, max_length=128, **kwargs
26
- )
27
-
28
-
29
20
  def _check_oneof(model_obj: BaseModel, fields: List[str]):
30
21
  has = []
31
22
  for field in fields:
@@ -56,6 +47,44 @@ def expand_var(value: Primitive) -> Primitive:
56
47
  )
57
48
 
58
49
 
50
+ def expand_vars(vars: Dict[str, Primitive]) -> Dict[str, Primitive]:
51
+ return {key: expand_var(value) for key, value in vars.items()}
52
+
53
+
54
+ def _represents_int(s: str) -> bool:
55
+ return re.match(r'[-+]?\d+$', s.strip()) is not None
56
+
57
+
58
+ def _represents_float(s: str) -> bool:
59
+ return re.match(r'[-+]?\d+\.\d+$', s.strip()) is not None
60
+
61
+
62
+ def _represents_bool(s: str) -> bool:
63
+ return s.lower().strip() in ['true', 'false', 'True', 'False']
64
+
65
+
66
+ def convert_to_primitive(value: Any) -> Primitive:
67
+ if _represents_int(value):
68
+ return int(value)
69
+ if _represents_float(value):
70
+ return float(value)
71
+ if _represents_bool(value):
72
+ return value.lower().strip() == 'true'
73
+ return str(value)
74
+
75
+
76
+ def expand_any_vars(vars: Dict[str, Any]) -> Dict[str, Primitive]:
77
+ converted_vars = {key: convert_to_primitive(value) for key, value in vars.items()}
78
+ return expand_vars(converted_vars)
79
+
80
+
81
+ def is_unique_by_name(statements: List['Statement']) -> List['Statement']:
82
+ names = {st.name for st in statements}
83
+ if len(names) != len(statements):
84
+ raise ValueError('Statement names must be unique.')
85
+ return statements
86
+
87
+
59
88
  class ExpectedOutcome(AutoEnum):
60
89
  ANY = alias('any') # type: ignore
61
90
  """Expected outcome for any outcome."""
@@ -459,9 +488,10 @@ that is correct and used as reference -- and should have the `accepted` outcome.
459
488
  default=[], description='Stress tests for the problem.'
460
489
  )
461
490
 
462
- statements: List[Statement] = Field(
463
- default=[], description='Statements for the problem.'
464
- )
491
+ statements: Annotated[
492
+ List[Statement],
493
+ AfterValidator(is_unique_by_name),
494
+ ] = Field(default=[], description='Statements for the problem.')
465
495
 
466
496
  # Vars to be re-used across the package.
467
497
  # - It will be passed as --key=value arguments to the validator.
@@ -475,9 +505,13 @@ that is correct and used as reference -- and should have the `accepted` outcome.
475
505
  description='Unit tests for components of this problem.',
476
506
  )
477
507
 
508
+ @property
509
+ def expanded_statements(self) -> List[Statement]:
510
+ return expand_statements(self.statements)
511
+
478
512
  @property
479
513
  def expanded_vars(self) -> Dict[str, Primitive]:
480
- return {key: expand_var(value) for key, value in self.vars.items()}
514
+ return expand_vars(self.vars)
481
515
 
482
516
  def timelimit_for_language(self, language: Optional[str]) -> int:
483
517
  res = self.timeLimit
@@ -525,3 +559,19 @@ that is correct and used as reference -- and should have the `accepted` outcome.
525
559
  {'i': i + 1},
526
560
  )
527
561
  return self
562
+
563
+ @model_validator(mode='after')
564
+ def check_checker_and_interactor_for_task_type(self):
565
+ if self.type == TaskType.BATCH:
566
+ if self.interactor is not None:
567
+ raise PydanticCustomError(
568
+ 'INTERACTOR_NOT_ALLOWED',
569
+ 'Interactor is not allowed for batch problems. Change the task type to COMMUNICATION.',
570
+ )
571
+ if self.type == TaskType.COMMUNICATION:
572
+ if self.checker is not None:
573
+ raise PydanticCustomError(
574
+ 'CHECKER_NOT_ALLOWED',
575
+ 'Checkers should not be specified for communication problems.',
576
+ )
577
+ return self
rbx/box/solutions.py CHANGED
@@ -48,6 +48,7 @@ from rbx.box.tasks import (
48
48
  from rbx.box.testcase_extractors import extract_generation_testcases
49
49
  from rbx.box.testcase_utils import (
50
50
  TestcaseEntry,
51
+ TestcaseInteractionParsingError,
51
52
  find_built_testcases,
52
53
  parse_interaction,
53
54
  print_interaction,
@@ -374,7 +375,11 @@ def print_best_output(output_files: List[pathlib.Path], empty_warning: bool = Fa
374
375
  if not output_file.is_file():
375
376
  continue
376
377
  if output_file.suffix == '.pio':
377
- print_interaction(parse_interaction(output_file))
378
+ try:
379
+ print_interaction(parse_interaction(output_file))
380
+ except TestcaseInteractionParsingError:
381
+ # Ignore parsing errors and proceed to next file.
382
+ continue
378
383
  else:
379
384
  console.console.print(output_file.read_text())
380
385
  return
@@ -1,7 +1,7 @@
1
1
  import pathlib
2
2
  import tempfile
3
3
  import typing
4
- from typing import Annotated, Dict, List, Optional, Tuple
4
+ from typing import Annotated, Any, Dict, List, Optional, Tuple
5
5
 
6
6
  import syncer
7
7
  import typer
@@ -9,7 +9,7 @@ import typer
9
9
  from rbx import annotations, console
10
10
  from rbx.box import environment, naming, package
11
11
  from rbx.box.formatting import href
12
- from rbx.box.schema import Package
12
+ from rbx.box.schema import Package, expand_any_vars
13
13
  from rbx.box.statements.builders import (
14
14
  BUILDER_LIST,
15
15
  PROBLEM_BUILDER_LIST,
@@ -217,7 +217,7 @@ def build_statement_bytes(
217
217
  overridden_params: Optional[Dict[ConversionType, ConversionStep]] = None,
218
218
  overridden_assets: Optional[List[Tuple[pathlib.Path, pathlib.Path]]] = None,
219
219
  use_samples: bool = True,
220
- is_editorial: bool = False,
220
+ custom_vars: Optional[Dict[str, Any]] = None,
221
221
  ) -> Tuple[bytes, StatementType]:
222
222
  overridden_params = overridden_params or {}
223
223
  overridden_assets = overridden_assets or []
@@ -258,10 +258,11 @@ def build_statement_bytes(
258
258
  output = bdr.build(
259
259
  input=last_content,
260
260
  context=StatementBuilderContext(
261
+ lang=statement.language,
261
262
  languages=get_environment_languages_for_statement(),
262
263
  params=params,
263
264
  root=pathlib.Path(td),
264
- editorial=is_editorial,
265
+ custom_vars=custom_vars,
265
266
  ),
266
267
  item=StatementBuilderProblem(
267
268
  package=pkg,
@@ -284,24 +285,23 @@ def build_statement(
284
285
  pkg: Package,
285
286
  output_type: Optional[StatementType] = None,
286
287
  use_samples: bool = True,
287
- is_editorial: bool = False,
288
+ custom_vars: Optional[Dict[str, Any]] = None,
288
289
  ) -> pathlib.Path:
289
290
  last_content, last_output = build_statement_bytes(
290
291
  statement,
291
292
  pkg,
292
293
  output_type=output_type,
293
294
  use_samples=use_samples,
294
- is_editorial=is_editorial,
295
+ custom_vars=custom_vars,
295
296
  short_name=naming.get_problem_shortname(),
296
297
  )
297
- statement_path = (
298
- package.get_build_path()
299
- / f'{statement.path.stem}{last_output.get_file_suffix()}'
298
+ statement_path = (package.get_build_path() / statement.name).with_suffix(
299
+ last_output.get_file_suffix()
300
300
  )
301
301
  statement_path.parent.mkdir(parents=True, exist_ok=True)
302
302
  statement_path.write_bytes(last_content)
303
303
  console.console.print(
304
- f'Statement built successfully for language '
304
+ f'Statement [item]{statement.name}[/item] built successfully for language '
305
305
  f'[item]{statement.language}[/item] at '
306
306
  f'{href(statement_path)}'
307
307
  )
@@ -313,13 +313,18 @@ def build_statement(
313
313
  @syncer.sync
314
314
  async def build(
315
315
  verification: environment.VerificationParam,
316
+ names: Annotated[
317
+ Optional[List[str]],
318
+ typer.Argument(
319
+ help='Names of statements to build.',
320
+ ),
321
+ ] = None,
316
322
  languages: Annotated[
317
323
  Optional[List[str]],
318
324
  typer.Option(
319
- default_factory=list,
320
325
  help='Languages to build statements for. If not specified, build statements for all available languages.',
321
326
  ),
322
- ],
327
+ ] = None,
323
328
  output: Annotated[
324
329
  Optional[StatementType],
325
330
  typer.Option(
@@ -331,9 +336,14 @@ async def build(
331
336
  bool,
332
337
  typer.Option(help='Whether to build the statement with samples or not.'),
333
338
  ] = True,
334
- editorial: Annotated[
335
- bool, typer.Option(help='Whether to add editorial blocks to the statements.')
336
- ] = False,
339
+ vars: Annotated[
340
+ Optional[List[str]],
341
+ typer.Option(
342
+ '-v',
343
+ '--vars',
344
+ help='Variables to be used in the statements.',
345
+ ),
346
+ ] = None,
337
347
  ):
338
348
  # At most run the validators, only in samples.
339
349
  if samples:
@@ -350,24 +360,31 @@ async def build(
350
360
  raise typer.Exit(1)
351
361
 
352
362
  pkg = package.find_problem_package_or_die()
353
- candidate_languages = languages
354
- if not candidate_languages:
355
- candidate_languages = sorted(set([st.language for st in pkg.statements]))
363
+ candidate_languages = set(languages or [])
364
+ candidate_names = set(names or [])
356
365
 
357
- for language in candidate_languages:
358
- candidates_for_lang = [st for st in pkg.statements if st.language == language]
359
- if not candidates_for_lang:
360
- console.console.print(
361
- f'[error]No statement found for language [item]{language}[/item].[/error]',
362
- )
363
- raise typer.Exit(1)
366
+ def should_process(st: Statement) -> bool:
367
+ if candidate_languages and st.language not in candidate_languages:
368
+ return False
369
+ if candidate_names and st.name not in candidate_names:
370
+ return False
371
+ return True
372
+
373
+ valid_statements = [st for st in pkg.expanded_statements if should_process(st)]
374
+
375
+ if not valid_statements:
376
+ console.console.print(
377
+ '[error]No statement found according to the specified criteria.[/error]',
378
+ )
379
+ raise typer.Exit(1)
364
380
 
381
+ for statement in valid_statements:
365
382
  build_statement(
366
- candidates_for_lang[0],
383
+ statement,
367
384
  pkg,
368
385
  output_type=output,
369
386
  use_samples=samples,
370
- is_editorial=editorial,
387
+ custom_vars=expand_any_vars(annotations.parse_dictionary_items(vars)),
371
388
  )
372
389
 
373
390
 
@@ -25,7 +25,11 @@ from rbx.box.statements.schema import (
25
25
  TexToPDF,
26
26
  rbxToTeX,
27
27
  )
28
- from rbx.box.testcase_utils import TestcaseInteraction, parse_interaction
28
+ from rbx.box.testcase_utils import (
29
+ TestcaseInteraction,
30
+ TestcaseInteractionParsingError,
31
+ parse_interaction,
32
+ )
29
33
 
30
34
 
31
35
  @dataclasses.dataclass
@@ -37,20 +41,22 @@ class StatementCodeLanguage:
37
41
 
38
42
  @dataclasses.dataclass
39
43
  class StatementBuilderContext:
44
+ lang: str
40
45
  languages: List[StatementCodeLanguage]
41
46
  params: ConversionStep
42
47
  root: pathlib.Path
43
- editorial: bool
48
+ custom_vars: Optional[Dict[str, Any]] = None
44
49
  vars: Optional[Dict[str, Primitive]] = None
45
50
 
46
51
  def build_jinja_kwargs(self) -> Dict[str, Any]:
47
52
  res = {
53
+ 'lang': self.lang,
48
54
  'languages': self.languages,
49
55
  'keyed_languages': {lang.id: lang for lang in self.languages},
50
- 'is_editorial': self.editorial,
51
56
  }
52
- if self.vars is not None:
53
- res['vars'] = self.vars
57
+ if self.vars is not None or self.custom_vars is not None:
58
+ res['vars'] = self.vars or {}
59
+ res['vars'].update(self.custom_vars or {})
54
60
  return res
55
61
 
56
62
 
@@ -82,7 +88,13 @@ class StatementSample(BaseModel):
82
88
 
83
89
  interaction = None
84
90
  if pio_path.is_file():
85
- interaction = parse_interaction(pio_path)
91
+ try:
92
+ interaction = parse_interaction(pio_path)
93
+ except TestcaseInteractionParsingError as e:
94
+ console.console.print(
95
+ f'Error parsing interactive sample: [error]{e}[/error]'
96
+ )
97
+ raise typer.Exit(1) from e
86
98
 
87
99
  return StatementSample(
88
100
  inputPath=input_path,
@@ -325,10 +337,6 @@ class rbxTeXBuilder(StatementBuilder):
325
337
  )
326
338
  blocks = statement_blocks.blocks
327
339
 
328
- # Remove editorial block when not editorial.
329
- if not context.editorial and 'editorial' in blocks:
330
- del blocks['editorial']
331
-
332
340
  problem_kwargs = problem.build_jinja_kwargs()
333
341
  problem_kwargs['problem']['blocks'] = blocks
334
342
  if statement_blocks.explanations is not None:
@@ -0,0 +1,49 @@
1
+ import collections
2
+ from typing import Any, List, TypeVar
3
+
4
+ from rbx.box.fields import merge_pydantic_models
5
+
6
+ TypeVarT = TypeVar('TypeVarT', bound=Any)
7
+
8
+
9
+ def expand_statements(statements: List[TypeVarT]) -> List[TypeVarT]:
10
+ deg = collections.defaultdict(int)
11
+ dependencies = collections.defaultdict(list)
12
+ for statement in statements:
13
+ if statement.extends is not None:
14
+ deg[statement.name] += 1
15
+ dependencies[statement.extends].append(statement.name)
16
+
17
+ # Topological sort.
18
+ # - We need to expand statements in the order of dependencies.
19
+ # - This is a simple topological sort.
20
+ # - If there are multiple statements with indegree 0, we choose the first one.
21
+ st_per_name = {}
22
+ expanded = {}
23
+ st = []
24
+ for statement in statements:
25
+ st_per_name[statement.name] = statement
26
+ if deg[statement.name] == 0:
27
+ st.append(statement)
28
+
29
+ while st:
30
+ statement = st.pop()
31
+ expanded_statement = statement.model_copy()
32
+ if statement.extends is not None:
33
+ expanded_statement = merge_pydantic_models(
34
+ expanded[statement.extends], statement
35
+ )
36
+
37
+ expanded[statement.name] = expanded_statement
38
+
39
+ for dep_name in dependencies[statement.name]:
40
+ deg[dep_name] -= 1
41
+ if deg[dep_name] == 0:
42
+ st.append(st_per_name[dep_name])
43
+
44
+ if len(expanded) != len(statements):
45
+ raise ValueError(
46
+ f'Failed to expand statements: only {len(expanded)} out of {len(statements)} were expanded. This means there is a cycle introduced by the `extends` field.'
47
+ )
48
+
49
+ return [expanded[statement.name] for statement in statements]
@@ -6,9 +6,10 @@ with Latex.
6
6
  import pathlib
7
7
  import re
8
8
  import typing
9
- from typing import Dict, Tuple, Union
9
+ from typing import Any, Dict, Tuple, Union
10
10
 
11
11
  import jinja2
12
+ import jinja2.runtime
12
13
  import typer
13
14
 
14
15
  from rbx import console
@@ -131,11 +132,56 @@ def path_stem(path: pathlib.Path) -> str:
131
132
  return path.stem
132
133
 
133
134
 
135
+ @jinja2.pass_context
136
+ def test_var_truthy(ctx: jinja2.runtime.Context, value: Any):
137
+ if isinstance(value, jinja2.Undefined):
138
+ return False
139
+ if value is None:
140
+ return False
141
+ return bool(value)
142
+
143
+
144
+ @jinja2.pass_context
145
+ def test_var_falsy(ctx: jinja2.runtime.Context, value: Any):
146
+ return not test_var_truthy(ctx, value)
147
+
148
+
149
+ @jinja2.pass_context
150
+ def test_var_null(ctx: jinja2.runtime.Context, value: Any):
151
+ if isinstance(value, jinja2.Undefined):
152
+ return True
153
+ if value is None:
154
+ return True
155
+ return False
156
+
157
+
158
+ @jinja2.pass_context
159
+ def test_var_nonnull(ctx: jinja2.runtime.Context, value: Any):
160
+ return not test_var_null(ctx, value)
161
+
162
+
134
163
  ######################################################################
135
164
  # Declare module functions
136
165
  ######################################################################
137
166
 
138
167
 
168
+ class StrictChainableUndefined(jinja2.StrictUndefined):
169
+ def __getattr__(self, name: str) -> 'StrictChainableUndefined':
170
+ # Raise AttributeError on requests for names that appear to be unimplemented
171
+ # dunder methods to avoid confusing Python with truthy non-method objects that
172
+ # do not implement the protocol being probed for. e.g., copy.copy(Undefined())
173
+ # fails spectacularly if getattr(Undefined(), '__setstate__') returns an
174
+ # Undefined object instead of raising AttributeError to signal that it does not
175
+ # support that style of object initialization.
176
+ if name[:2] == '__' and name[-2:] == '__':
177
+ raise AttributeError(name)
178
+
179
+ return self
180
+
181
+ def __getitem__(self, _name: str) -> 'StrictChainableUndefined': # type: ignore[override]
182
+ return self
183
+
184
+
139
185
  class JinjaDictWrapper(dict):
140
186
  def __init__(self, *args, key='dict object', **kwargs):
141
187
  super().__init__(*args, **kwargs)
@@ -145,7 +191,9 @@ class JinjaDictWrapper(dict):
145
191
  try:
146
192
  return super().__getitem__(key)
147
193
  except KeyError:
148
- return jinja2.StrictUndefined(hint=f'"{key}" was not found in "{self.key}"')
194
+ return StrictChainableUndefined(
195
+ hint=f'"{key}" was not found in "{self.key}"'
196
+ )
149
197
 
150
198
 
151
199
  def add_builtin_filters(j2_env: jinja2.Environment):
@@ -155,6 +203,13 @@ def add_builtin_filters(j2_env: jinja2.Environment):
155
203
  j2_env.filters['stem'] = path_stem
156
204
 
157
205
 
206
+ def add_builtin_tests(j2_env: jinja2.Environment):
207
+ j2_env.tests['truthy'] = test_var_truthy
208
+ j2_env.tests['falsy'] = test_var_falsy
209
+ j2_env.tests['null'] = test_var_null
210
+ j2_env.tests['nonnull'] = test_var_nonnull
211
+
212
+
158
213
  def render_latex_template(path_templates, template_filename, template_vars=None) -> str:
159
214
  """Render a latex template, filling in its template variables
160
215
 
@@ -168,9 +223,10 @@ def render_latex_template(path_templates, template_filename, template_vars=None)
168
223
  j2_env = jinja2.Environment(
169
224
  loader=jinja2.FileSystemLoader(path_templates),
170
225
  **J2_ARGS,
171
- undefined=jinja2.StrictUndefined,
226
+ undefined=StrictChainableUndefined,
172
227
  )
173
228
  add_builtin_filters(j2_env)
229
+ add_builtin_tests(j2_env)
174
230
  template = j2_env.get_template(template_filename)
175
231
  try:
176
232
  return template.render(**var_dict) # type: ignore
@@ -198,9 +254,10 @@ def render_latex_template_blocks(
198
254
  j2_env = jinja2.Environment(
199
255
  loader=jinja2.FileSystemLoader(path_templates),
200
256
  **J2_ARGS,
201
- undefined=jinja2.StrictUndefined,
257
+ undefined=StrictChainableUndefined,
202
258
  )
203
259
  add_builtin_filters(j2_env)
260
+ add_builtin_tests(j2_env)
204
261
  template = j2_env.get_template(template_filename)
205
262
  ctx = template.new_context(var_dict) # type: ignore
206
263
  try:
@@ -2,11 +2,24 @@ from __future__ import annotations
2
2
 
3
3
  import pathlib
4
4
  from enum import Enum
5
- from typing import List, Literal, Union
5
+ from typing import Annotated, List, Literal, Optional, Union
6
6
 
7
- from pydantic import BaseModel, ConfigDict, Field
7
+ from pydantic import AfterValidator, BaseModel, ConfigDict, Field
8
8
 
9
9
  from rbx.autoenum import AutoEnum, alias
10
+ from rbx.box.fields import FNameField
11
+ from rbx.box.lang import is_valid_lang_code
12
+
13
+
14
+ def validate_statement_language(lang: str):
15
+ if not is_valid_lang_code(lang) or not lang.islower():
16
+ raise ValueError(
17
+ f'Invalid statement language: {lang}. Language must be a valid lowercase ISO 639-1 code.'
18
+ )
19
+ return lang
20
+
21
+
22
+ StatementLanguage = Annotated[str, AfterValidator(validate_statement_language)]
10
23
 
11
24
 
12
25
  ### Conversion types
@@ -95,13 +108,28 @@ class StatementType(AutoEnum):
95
108
  class Statement(BaseModel):
96
109
  model_config = ConfigDict(extra='forbid')
97
110
 
111
+ name: str = FNameField(description='Name of this statement.')
112
+
113
+ extends: Optional[str] = FNameField(
114
+ default=None,
115
+ description='Name of the statement that this statement extends.',
116
+ )
117
+
118
+ language: StatementLanguage = Field(
119
+ default='en', description='Language code of this statement (ISO 639-1).'
120
+ )
121
+
98
122
  title: str = Field(
99
- description='Name of the problem, as it appears in the statement.'
123
+ default='', description='Name of the problem, as it appears in the statement.'
100
124
  )
101
125
 
102
- path: pathlib.Path = Field(description='Path to the input statement file.')
126
+ path: pathlib.Path = Field(
127
+ default_factory=pathlib.Path, description='Path to the input statement file.'
128
+ )
103
129
 
104
- type: StatementType = Field(description='Type of the input statement file.')
130
+ type: StatementType = Field(
131
+ default=StatementType.rbxTeX, description='Type of the input statement file.'
132
+ )
105
133
 
106
134
  steps: List[ConversionStep] = Field(
107
135
  default=[],
@@ -134,7 +162,3 @@ the statement. Files will be included in the same folder as the statement file,
134
162
  their relativeness. Can be glob pattern as well, such as `imgs/*.png`.
135
163
  """,
136
164
  )
137
-
138
- language: str = Field(
139
- default='en', description='Language this is statement is written in.'
140
- )
rbx/box/testcase_utils.py CHANGED
@@ -160,6 +160,10 @@ def fill_output_for_defined_testcase(testcase: Testcase) -> Testcase:
160
160
  return res
161
161
 
162
162
 
163
+ class TestcaseInteractionParsingError(Exception):
164
+ pass
165
+
166
+
163
167
  def parse_interaction(file: pathlib.Path) -> TestcaseInteraction:
164
168
  entries = []
165
169
  with file.open('r') as f:
@@ -167,53 +171,21 @@ def parse_interaction(file: pathlib.Path) -> TestcaseInteraction:
167
171
  interactor_prefix = f.readline().strip()
168
172
  solution_prefix = f.readline().strip()
169
173
  except Exception:
170
- console.console.print(
171
- f'[error]Failed to read interaction file [item]{file}[/item]. Expected the first two lines to be the interactor and solution prefixes.[/error]'
172
- )
173
- raise typer.Exit(1) from None
174
-
175
- # Crop file.
176
- rest = f.read()
177
- start = 0
178
-
179
- def _find_next_prefix(start: int) -> Optional[Tuple[int, int]]:
180
- interactor_idx = rest.find(interactor_prefix, start)
181
- solution_idx = rest.find(solution_prefix, start)
182
- if interactor_idx == -1 and solution_idx == -1:
183
- return None
184
- if interactor_idx == -1:
185
- return (solution_idx, solution_idx + len(solution_prefix))
186
- if solution_idx == -1:
187
- return (interactor_idx, interactor_idx + len(interactor_prefix))
188
- if interactor_idx < solution_idx:
189
- return (interactor_idx, interactor_idx + len(interactor_prefix))
190
- return (solution_idx, solution_idx + len(solution_prefix))
191
-
192
- def _find_next_block() -> Optional[Tuple[int, Tuple[int, int]]]:
193
- prefix = _find_next_prefix(start)
194
- if prefix is None:
195
- return None
196
- prefix_start, prefix_end = prefix
197
- prefix = rest[prefix_start:prefix_end]
198
- pipe = 1 if prefix == solution_prefix else 0
199
-
200
- nxt = _find_next_prefix(prefix_end)
201
- if nxt is None:
202
- return (pipe, (prefix_end, len(rest)))
203
- nxt_start, _ = nxt
204
- return (pipe, (prefix_end, nxt_start))
205
-
206
- # TODO: optimize
207
- blocks = 0
208
- MAX_BLOCKS = 1024
209
- while blocks < MAX_BLOCKS:
210
- block = _find_next_block()
211
- if block is None:
212
- break
213
- pipe, (st, nd) = block
214
- entries.append(TestcaseInteractionEntry(data=rest[st:nd], pipe=pipe))
215
- start = nd
216
- blocks += 1
174
+ raise TestcaseInteractionParsingError(
175
+ f'Failed to read interaction file {file}. Expected the first two lines to be the interactor and solution prefixes.'
176
+ ) from None
177
+
178
+ while line := f.readline().strip():
179
+ if line.startswith(interactor_prefix):
180
+ stripped = line[len(interactor_prefix) :].strip()
181
+ entries.append(TestcaseInteractionEntry(data=stripped, pipe=0))
182
+ elif line.startswith(solution_prefix):
183
+ stripped = line[len(solution_prefix) :].strip()
184
+ entries.append(TestcaseInteractionEntry(data=stripped, pipe=1))
185
+ else:
186
+ raise TestcaseInteractionParsingError(
187
+ f'Invalid line in interaction file {file}. Expected the line to start with the interactor or solution prefix ({interactor_prefix} or {solution_prefix}).'
188
+ ) from None
217
189
 
218
190
  return TestcaseInteraction(
219
191
  prefixes=(interactor_prefix, solution_prefix),
File without changes
File without changes