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
@@ -10,8 +10,8 @@ import typer
10
10
  from rbx import console
11
11
  from rbx.box import header, package
12
12
  from rbx.box.generators import get_all_built_testcases
13
+ from rbx.box.lang import code_to_langs, is_valid_lang_code
13
14
  from rbx.box.packaging.polygon import polygon_api as api
14
- from rbx.box.packaging.polygon.packager import code_to_langs, is_valid_lang_code
15
15
  from rbx.box.schema import CodeItem, ExpectedOutcome, Solution, TaskType, Testcase
16
16
  from rbx.box.statements.build_statements import get_relative_assets
17
17
  from rbx.box.statements.builders import (
@@ -20,7 +20,11 @@ from rbx.box.statements.builders import (
20
20
  render_jinja_blocks,
21
21
  )
22
22
  from rbx.box.statements.schema import Statement, StatementType
23
- from rbx.box.testcase_utils import get_alternate_interaction_texts, parse_interaction
23
+ from rbx.box.testcase_utils import (
24
+ TestcaseInteractionParsingError,
25
+ get_alternate_interaction_texts,
26
+ parse_interaction,
27
+ )
24
28
 
25
29
  _API_URL = 'https://polygon.codeforces.com/api'
26
30
 
@@ -163,17 +167,23 @@ def _get_test_params_for_statement(
163
167
 
164
168
  pio_path = testcase.outputPath.with_suffix('.pio')
165
169
  if pio_path.is_file():
166
- interaction = parse_interaction(pio_path)
167
- res['test_input_for_statements'], res['test_output_for_statements'] = (
168
- get_alternate_interaction_texts(interaction)
169
- )
170
- else:
171
- pin_path = testcase.outputPath.with_suffix('.pin')
172
- if pin_path.is_file():
173
- res['test_input_for_statements'] = pin_path.read_text()
174
- pout_path = testcase.outputPath.with_suffix('.pout')
175
- if pout_path.is_file():
176
- res['test_output_for_statements'] = pout_path.read_text()
170
+ try:
171
+ interaction = parse_interaction(pio_path)
172
+ except TestcaseInteractionParsingError:
173
+ pass
174
+ else:
175
+ res['test_input_for_statements'], res['test_output_for_statements'] = (
176
+ get_alternate_interaction_texts(interaction)
177
+ )
178
+ return res
179
+
180
+ # .pio does not exist or is not parseable, fallback to .pin and .pout.
181
+ pin_path = testcase.outputPath.with_suffix('.pin')
182
+ if pin_path.is_file():
183
+ res['test_input_for_statements'] = pin_path.read_text()
184
+ pout_path = testcase.outputPath.with_suffix('.pout')
185
+ if pout_path.is_file():
186
+ res['test_output_for_statements'] = pout_path.read_text()
177
187
  return res
178
188
 
179
189
 
@@ -229,7 +239,7 @@ def _upload_solutions(problem: api.Problem):
229
239
 
230
240
  def _get_statement_for_language(language: str) -> Optional[Statement]:
231
241
  pkg = package.find_problem_package_or_die()
232
- for statement in pkg.statements:
242
+ for statement in pkg.expanded_statements:
233
243
  if statement.language == language:
234
244
  return statement
235
245
  return None
@@ -290,11 +300,15 @@ def _upload_statement(problem: api.Problem, preserve_language: bool = False):
290
300
  pkg = package.find_problem_package_or_die()
291
301
 
292
302
  languages = set()
293
- for statement in pkg.statements:
303
+ for statement in pkg.expanded_statements:
294
304
  if not is_valid_lang_code(statement.language):
295
305
  continue
296
306
  languages.add(statement.language)
297
- for language in languages:
307
+
308
+ uploaded_languages = set()
309
+
310
+ # Prioritize English statements.
311
+ for language in ['en'] + list(languages):
298
312
  statement = _get_statement_for_language(language)
299
313
  if statement is None:
300
314
  continue
@@ -304,6 +318,10 @@ def _upload_statement(problem: api.Problem, preserve_language: bool = False):
304
318
  console.console.print(
305
319
  f'Uploading statement for language [item]{language}[/item] (polygon language: [item]{statement_lang}[/item])...'
306
320
  )
321
+ uploaded_language = statement_lang if preserve_language else 'english'
322
+ if uploaded_language in uploaded_languages:
323
+ continue
324
+ uploaded_languages.add(uploaded_language)
307
325
  if not preserve_language and statement_lang != 'english':
308
326
  console.console.print(
309
327
  '[warning]By default, Polygon statements are uploaded in English.\n'
@@ -322,7 +340,7 @@ def _upload_statement(problem: api.Problem, preserve_language: bool = False):
322
340
  notes=_get_notes_with_explanations(blocks) or '',
323
341
  )
324
342
  problem.save_statement(
325
- lang=statement_lang if preserve_language else 'english',
343
+ lang=uploaded_language,
326
344
  problem_statement=polygon_statement,
327
345
  )
328
346
 
rbx/box/remote.py CHANGED
@@ -52,14 +52,14 @@ class BocaExpander(Expander):
52
52
  return [str(self.get_boca_path(run_number, site_number)) + '.*']
53
53
 
54
54
  def expand(self, path: pathlib.Path) -> Optional[pathlib.Path]:
55
- from rbx.box.packaging.boca import upload as boca_upload
55
+ from rbx.box.tooling.boca import scraper as boca_upload
56
56
 
57
57
  match = self.get_match(str(path))
58
58
  if match is None:
59
59
  return None
60
60
  run_number, site_number = match
61
61
 
62
- boca_uploader = boca_upload.get_boca_uploader()
62
+ boca_uploader = boca_upload.get_boca_scraper()
63
63
  boca_uploader.login()
64
64
  sol_path = boca_uploader.download_run(
65
65
  run_number, site_number, self.get_boca_folder()
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: