Skip to content

Commit 3329a84

Browse files
authored
Merge pull request #608 from dodona-edu/enhancement/stdin
Improve file handling for stdin
2 parents 72d62b7 + 19b04b2 commit 3329a84

23 files changed

+1008
-140
lines changed

tested/configs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ class Options:
6767
"""
6868

6969

70-
@fallback_field(get_converter(), {"testplan": "test_suite", "plan_name": "test_suite"})
70+
@fallback_field({"testplan": "test_suite", "plan_name": "test_suite"})
7171
@define
7272
class DodonaConfig:
7373
resources: Path

tested/dsl/schema-strict.json

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -276,11 +276,18 @@
276276
},
277277
"stdin" : {
278278
"description" : "Stdin for this context",
279-
"type" : [
280-
"string",
281-
"number",
282-
"integer",
283-
"boolean"
279+
"oneOf" : [
280+
{
281+
"type" : [
282+
"string",
283+
"number",
284+
"integer",
285+
"boolean"
286+
]
287+
},
288+
{
289+
"$ref": "#/definitions/fileData"
290+
}
284291
]
285292
},
286293
"arguments" : {
@@ -890,6 +897,35 @@
890897
"$ref" : "#/definitions/fileConfigurationOptions"
891898
}
892899
}
900+
},
901+
"fileData": {
902+
"type": "object",
903+
"additionalProperties" : false,
904+
"anyOf" : [
905+
{
906+
"required" : [
907+
"content"
908+
]
909+
},
910+
{
911+
"required" : [
912+
"path"
913+
]
914+
}
915+
],
916+
"properties": {
917+
"content": {
918+
"type": [
919+
"string",
920+
"path"
921+
],
922+
"description" : "Content of the file, which will be provided inline or written to disk in the workdir. If a !path, the file contents will be read from the provided path."
923+
},
924+
"path": {
925+
"type": "string",
926+
"description" : "Path to the file, relative to the workdir. Used to display in the output."
927+
}
928+
}
893929
}
894930
}
895931
}

tested/dsl/schema.json

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -276,11 +276,18 @@
276276
},
277277
"stdin" : {
278278
"description" : "Stdin for this context",
279-
"type" : [
280-
"string",
281-
"number",
282-
"integer",
283-
"boolean"
279+
"oneOf" : [
280+
{
281+
"type" : [
282+
"string",
283+
"number",
284+
"integer",
285+
"boolean"
286+
]
287+
},
288+
{
289+
"$ref": "#/definitions/fileData"
290+
}
284291
]
285292
},
286293
"arguments" : {
@@ -884,6 +891,32 @@
884891
"$ref" : "#/definitions/fileConfigurationOptions"
885892
}
886893
}
894+
},
895+
"fileData": {
896+
"type": "object",
897+
"additionalProperties" : false,
898+
"anyOf" : [
899+
{
900+
"required" : [
901+
"content"
902+
]
903+
},
904+
{
905+
"required" : [
906+
"path"
907+
]
908+
}
909+
],
910+
"properties": {
911+
"content": {
912+
"type": "string",
913+
"description" : "Content of the file, which will be provided inline or written to disk in the workdir. If a !path, the file contents will be read from the provided path."
914+
},
915+
"path": {
916+
"type": "string",
917+
"description" : "Path to the file, relative to the workdir. Used to display in the output."
918+
}
919+
}
887920
}
888921
}
889922
}

tested/dsl/translate_parser.py

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
Value,
4646
)
4747
from tested.testsuite import (
48+
ContentPath,
4849
Context,
4950
CustomCheckOracle,
5051
EmptyChannel,
@@ -88,9 +89,22 @@ class ReturnOracle(dict):
8889
pass
8990

9091

92+
class ContentPathString(str):
93+
pass
94+
95+
9196
OptionDict = dict[str, int | bool]
9297
YamlObject = (
93-
YamlDict | list | bool | float | int | str | None | ExpressionString | ReturnOracle
98+
YamlDict
99+
| list
100+
| bool
101+
| float
102+
| int
103+
| str
104+
| None
105+
| ExpressionString
106+
| ReturnOracle
107+
| ContentPathString
94108
)
95109

96110

@@ -138,6 +152,12 @@ def _return_oracle(loader: yaml.Loader, node: yaml.Node) -> ReturnOracle:
138152
return ReturnOracle(result)
139153

140154

155+
def _return_path(loader: yaml.Loader, node: yaml.Node) -> ContentPathString:
156+
result = _parse_yaml_value(loader, node)
157+
assert isinstance(result, str), f"A path must be a string, got {result}"
158+
return ContentPathString(result)
159+
160+
141161
def _parse_yaml(yaml_stream: str) -> YamlObject:
142162
"""
143163
Parse a string or stream to YAML.
@@ -148,6 +168,7 @@ def _parse_yaml(yaml_stream: str) -> YamlObject:
148168
yaml.add_constructor("!" + actual_type, _custom_type_constructors, loader)
149169
yaml.add_constructor("!expression", _expression_string, loader)
150170
yaml.add_constructor("!oracle", _return_oracle, loader)
171+
yaml.add_constructor("!path", _return_path, loader)
151172

152173
try:
153174
return yaml.load(yaml_stream, loader)
@@ -187,6 +208,10 @@ def is_expression(_checker: TypeChecker, instance: Any) -> bool:
187208
return isinstance(instance, ExpressionString)
188209

189210

211+
def is_path(_checker: TypeChecker, instance: Any) -> bool:
212+
return isinstance(instance, ContentPathString)
213+
214+
190215
def load_schema_validator(
191216
dsl_object: YamlObject = None, file: str = "schema-strict.json"
192217
) -> Validator:
@@ -215,9 +240,13 @@ def validate_tested_dsl_expression(value: object) -> bool:
215240
schema_object = json.load(schema_file)
216241

217242
original_validator: Type[Validator] = validator_for(schema_object)
218-
type_checker = original_validator.TYPE_CHECKER.redefine(
219-
"oracle", is_oracle
220-
).redefine("expression", is_expression)
243+
type_checker = original_validator.TYPE_CHECKER.redefine_many(
244+
{
245+
"oracle": is_oracle,
246+
"expression": is_expression,
247+
"path": is_path,
248+
}
249+
)
221250
format_checker = original_validator.FORMAT_CHECKER
222251
format_checker.checks("tested-dsl-expression", SyntaxError)(
223252
validate_tested_dsl_expression
@@ -469,16 +498,16 @@ def _convert_text_output_channel(
469498
data = raw_data
470499

471500
if isinstance(stream, str):
472-
return TextOutputChannel(data=data, oracle=GenericTextOracle(options=config))
501+
return TextOutputChannel(content=data, oracle=GenericTextOracle(options=config))
473502
else:
474503
assert isinstance(stream, dict)
475504
if "oracle" not in stream or stream["oracle"] == "builtin":
476505
return TextOutputChannel(
477-
data=data, oracle=GenericTextOracle(options=config)
506+
content=data, oracle=GenericTextOracle(options=config)
478507
)
479508
elif stream["oracle"] == "custom_check":
480509
return TextOutputChannel(
481-
data=data, oracle=_convert_custom_check_oracle(stream)
510+
content=data, oracle=_convert_custom_check_oracle(stream)
482511
)
483512
raise TypeError(f"Unknown text oracle type: {stream['oracle']}")
484513

@@ -599,8 +628,33 @@ def _convert_testcase(testcase: YamlDict, context: DslContext) -> Testcase:
599628
return_channel = IgnoredChannel.IGNORED if "statement" in testcase else None
600629
else:
601630
if "stdin" in testcase:
602-
assert isinstance(testcase["stdin"], str)
603-
stdin = TextData(data=_ensure_trailing_newline(testcase["stdin"]))
631+
if isinstance(testcase["stdin"], dict):
632+
stdin_object = testcase["stdin"]
633+
raw_content = stdin_object.get("content")
634+
raw_path = stdin_object.get("path")
635+
636+
if isinstance(raw_content, ContentPathString):
637+
file_content = ContentPath(path=raw_content)
638+
elif isinstance(raw_content, str):
639+
file_content = _ensure_trailing_newline(raw_content)
640+
elif isinstance(raw_path, str):
641+
file_content = ContentPath(path=raw_path)
642+
else:
643+
assert (
644+
False
645+
), f"Invalid stdin content is required but got {type(raw_content)}"
646+
647+
assert raw_path is None or isinstance(
648+
raw_path, str
649+
), "Path must be a string if given."
650+
651+
file_path = raw_path
652+
else:
653+
assert isinstance(testcase["stdin"], str)
654+
file_content = _ensure_trailing_newline(testcase["stdin"])
655+
file_path = None
656+
657+
stdin = TextData(content=file_content, path=file_path)
604658
else:
605659
stdin = EmptyChannel.NONE
606660
arguments = testcase.get("arguments", [])

tested/judge/execution.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import itertools
22
import logging
3+
import shutil
34
from pathlib import Path
45

56
from attrs import define
@@ -16,6 +17,7 @@
1617
)
1718
from tested.languages.conventionalize import selector_name
1819
from tested.languages.preparation import exception_file, value_file
20+
from tested.testsuite import ContentPath
1921
from tested.utils import safe_del
2022

2123
_logger = logging.getLogger(__name__)
@@ -180,6 +182,26 @@ def set_up_unit(
180182
# See https://github.com/dodona-edu/universal-judge/issues/57
181183
destination.hardlink_to(origin)
182184

185+
# Create dynamically generated files if necessary.
186+
dynamically_generated_file = unit.get_dynamically_generated_files()
187+
if dynamically_generated_file is not None:
188+
destination = execution_dir / dynamically_generated_file.path
189+
190+
if isinstance(dynamically_generated_file.content, ContentPath):
191+
_logger.debug(
192+
f"Copying input file %s to %s",
193+
dynamically_generated_file.content.path,
194+
destination,
195+
)
196+
source_file = (
197+
bundle.config.resources / dynamically_generated_file.content.path
198+
)
199+
shutil.copy2(source_file, destination)
200+
else:
201+
_logger.debug(f"Creating dynamically generated file %s", destination)
202+
destination.parent.mkdir(parents=True, exist_ok=True)
203+
destination.write_text(dynamically_generated_file.content)
204+
183205
return execution_dir, dependencies
184206

185207

tested/judge/planning.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from tested.dodona import AnnotateCode, Message, Status
1414
from tested.languages.conventionalize import execution_name
1515
from tested.languages.language import FileFilter
16-
from tested.testsuite import Context, EmptyChannel, MainInput
16+
from tested.testsuite import ContentPath, Context, EmptyChannel, MainInput
1717

1818

1919
@define
@@ -33,6 +33,12 @@ class PlannedContext:
3333
context_index: int
3434

3535

36+
@define(frozen=True)
37+
class DynamicallyGeneratedFile:
38+
path: str
39+
content: ContentPath | str
40+
41+
3642
@define
3743
class PlannedExecutionUnit:
3844
"""
@@ -56,6 +62,27 @@ def has_main_testcase(self) -> bool:
5662
def has_exit_testcase(self) -> bool:
5763
return self.contexts[-1].context.has_exit_testcase()
5864

65+
def get_dynamically_generated_files(self) -> DynamicallyGeneratedFile | None:
66+
if not self.has_main_testcase():
67+
return None
68+
69+
stdin = cast(MainInput, self.contexts[0].context.testcases[0].input).stdin
70+
71+
if stdin == EmptyChannel.NONE:
72+
return None
73+
74+
if not stdin.is_dynamically_generated():
75+
return None
76+
77+
# For type checking, in the future should be removable.
78+
if stdin.path is None:
79+
return None
80+
81+
return DynamicallyGeneratedFile(
82+
path=stdin.path,
83+
content=stdin.content,
84+
)
85+
5986

6087
@define
6188
class ExecutionPlan:

tested/languages/generation.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,12 +128,16 @@ def get_readable_input(
128128
args = f"$ {command}"
129129
# Determine the stdin
130130
if isinstance(case.input.stdin, TextData):
131-
stdin = case.input.stdin.get_data_as_string(bundle.config.resources)
131+
if case.input.stdin.path is not None:
132+
stdin = Path(case.input.stdin.path)
133+
else:
134+
stdin = case.input.stdin.get_data_as_string(bundle.config.resources)
132135
else:
133136
stdin = ""
134137

135-
# If we have both stdin and arguments, we use a here-document.
136-
if case.input.arguments and stdin:
138+
if stdin and isinstance(stdin, Path):
139+
text = f"${args} < {stdin}"
140+
elif case.input.arguments and stdin:
137141
assert stdin[-1] == "\n", "stdin must end with a newline"
138142
delimiter = _get_heredoc_token(stdin)
139143
text = f"{args} << '{delimiter}'\n{stdin}{delimiter}"

tested/languages/preparation.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -487,10 +487,10 @@ def prepare_context(
487487
"""
488488
language = bundle.config.programming_language
489489
resources = bundle.config.resources
490-
before_code = context.before.get(language, TextData(data="")).get_data_as_string(
490+
before_code = context.before.get(language, TextData(content="")).get_data_as_string(
491491
resources
492492
)
493-
after_code = context.after.get(language, TextData(data="")).get_data_as_string(
493+
after_code = context.after.get(language, TextData(content="")).get_data_as_string(
494494
resources
495495
)
496496
testcases, evaluator_names = prepare_testcases(bundle, context)

tested/oracles/common.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,6 @@ class OracleResult:
7474

7575

7676
@fallback_field(
77-
get_converter(),
7877
{
7978
"readableExpected": "readable_expected",
8079
"readableActual": "readable_actual",

0 commit comments

Comments
 (0)