Skip to content

Commit 46533d9

Browse files
committed
[problem] Update parsing of problem.yaml based on Kattis/problem-package-format#372 (#437)
* [problem] Update parsing of problem.yaml based on Kattis/problem-package-format#372 Changes: - **C1**: Keywords are now a list of strings (we used to still parse them as a single string, woops) - **C2**: No change needed, this was just a "bug" in the human-readable text of the specification - **C3**: Add constraints to float/int types in `limits` and warnings when any of the values are out of range - **C5**: Do not allow lists to be empty (if a list-field is optional, it should be either `None` or a non-empty list) The discussion for **C4** was moved to Kattis/problem-package-format#378 and is pending consensus, and the proposals for **C6**, **Q1**, and **Q2** were dropped. * [problem] Fix parsing of ProblemSource, thanks to Thore's extra tests * [test] Add some more tests for license/rights_owner Note that I haven't thoroughly tested the combination of `license` and `rights_owner`. Similar to embargo_until, BAPCtools doesn't really do much with this information anyway, so the parser there is currently quite lenient, and as such I'll consider it out-of-scope for this PR. If others feel like improving and adding tests for this, feel free to do so 🙂
1 parent b24607a commit 46533d9

File tree

4 files changed

+288
-57
lines changed

4 files changed

+288
-57
lines changed

bin/problem.py

+61-46
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import datetime
12
import re
23
import sys
34
import threading
@@ -89,8 +90,16 @@ class ProblemSources(list[ProblemSource]):
8990
def __init__(
9091
self,
9192
yaml_data: dict[str, Any],
92-
problem_settings: "ProblemSettings",
9393
):
94+
def source_from_dict(source_dict: dict[str, str]) -> ProblemSource:
95+
name = parse_setting(source_dict, "name", "")
96+
if not name:
97+
warn("problem.yaml: 'name' is required in source")
98+
return ProblemSource(
99+
name,
100+
parse_optional_setting(source_dict, "url", str),
101+
)
102+
94103
parse_deprecated_setting(yaml_data, "source_url", "source.url")
95104
if "source" not in yaml_data:
96105
return
@@ -99,23 +108,17 @@ def __init__(
99108
return
100109
if isinstance(yaml_data["source"], dict):
101110
source = parse_setting(yaml_data, "source", dict[str, str]())
102-
self.append(
103-
ProblemSource(
104-
parse_setting(source, "name", ""),
105-
parse_optional_setting(source, "url", str),
106-
)
107-
)
111+
self.append(source_from_dict(source))
108112
return
109113
if isinstance(yaml_data["source"], list):
110114
sources = parse_setting(yaml_data, "source", list[dict[str, str]]())
111-
for raw_source in sources:
112-
source = parse_setting(raw_source, "source", dict[str, str]())
113-
self.append(
114-
ProblemSource(
115-
parse_setting(source, "name", ""),
116-
parse_optional_setting(source, "url", str),
117-
)
118-
)
115+
for i, source in enumerate(sources):
116+
if isinstance(source, str):
117+
self.append(ProblemSource(source))
118+
elif isinstance(source, dict):
119+
self.append(source_from_dict(source))
120+
else:
121+
warn(f"problem.yaml key 'source[{i}]' does not have the correct type")
119122
return
120123
warn("problem.yaml key 'source' does not have the correct type")
121124

@@ -134,31 +137,48 @@ def __init__(
134137
time_multipliers = parse_setting(yaml_data, "time_multipliers", dict[str, Any]())
135138

136139
parse_deprecated_setting(yaml_data, "time_multiplier", "ac_to_time_limit")
137-
self.ac_to_time_limit = parse_setting(time_multipliers, "ac_to_time_limit", 2.0)
140+
self.ac_to_time_limit = parse_setting(time_multipliers, "ac_to_time_limit", 2.0, ">= 1")
138141
parse_deprecated_setting(yaml_data, "time_safety_margin", "time_limit_to_tle")
139-
self.time_limit_to_tle = parse_setting(time_multipliers, "time_limit_to_tle", 1.5)
142+
self.time_limit_to_tle = parse_setting(time_multipliers, "time_limit_to_tle", 1.5, ">= 1")
140143

141144
check_unknown_keys(time_multipliers, "limits.time_multipliers")
142145

143-
time_limit = parse_optional_setting(yaml_data, "time_limit", float) # in seconds
144-
self.time_resolution: float = parse_setting(yaml_data, "time_resolution", 1.0)
145-
self.memory: int = parse_setting(yaml_data, "memory", 2048) # in MiB
146-
self.output: int = parse_setting(yaml_data, "output", 8) # in MiB
147-
self.code: int = parse_setting(yaml_data, "code", 128) # in KiB
148-
self.compilation_time: int = parse_setting(yaml_data, "compilation_time", 60) # in seconds
146+
self.time_limit_is_default: bool = "time_limit" not in yaml_data
147+
self.time_limit: float = parse_setting(yaml_data, "time_limit", 1.0, "> 0") # in seconds
148+
self.time_resolution: float = parse_setting(yaml_data, "time_resolution", 1.0, "> 0")
149+
self.memory: int = parse_setting(yaml_data, "memory", 2048, "> 0") # in MiB
150+
self.output: int = parse_setting(yaml_data, "output", 8, "> 0") # in MiB
151+
self.code: int = parse_setting(yaml_data, "code", 128, "> 0") # in KiB
152+
self.compilation_time: int = parse_setting(
153+
yaml_data, "compilation_time", 60, "> 0"
154+
) # in seconds
149155
self.compilation_memory: int = parse_setting(
150-
yaml_data, "compilation_memory", 2048
156+
yaml_data, "compilation_memory", 2048, "> 0"
151157
) # in MiB
152-
self.validation_time: int = parse_setting(yaml_data, "validation_time", 60) # in seconds
153-
self.validation_memory: int = parse_setting(yaml_data, "validation_memory", 2048) # in MiB
154-
self.validation_output: int = parse_setting(yaml_data, "validation_output", 8) # in MiB
155-
self.validation_passes: Optional[int] = parse_optional_setting(
156-
yaml_data, "validation_passes", int
157-
)
158+
self.validation_time: int = parse_setting(
159+
yaml_data, "validation_time", 60, "> 0"
160+
) # in seconds
161+
self.validation_memory: int = parse_setting(
162+
yaml_data, "validation_memory", 2048, "> 0"
163+
) # in MiB
164+
self.validation_output: int = parse_setting(
165+
yaml_data, "validation_output", 8, "> 0"
166+
) # in MiB
167+
if problem_settings.multi_pass:
168+
self.validation_passes: Optional[int] = parse_setting(
169+
yaml_data, "validation_passes", 2, ">= 2"
170+
)
171+
elif "validation_passes" in yaml_data:
172+
yaml_data.pop("validation_passes")
173+
warn("limit: validation_passes is only used for multi-pass problems. SKIPPED.")
158174

159175
# BAPCtools extensions:
160-
self.generator_time: int = parse_setting(yaml_data, "generator_time", 60) # in seconds
161-
self.visualizer_time: int = parse_setting(yaml_data, "visualizer_time", 60) # in seconds
176+
self.generator_time: int = parse_setting(
177+
yaml_data, "generator_time", 60, "> 0"
178+
) # in seconds
179+
self.visualizer_time: int = parse_setting(
180+
yaml_data, "visualizer_time", 60, "> 0"
181+
) # in seconds
162182

163183
# warn for deprecated timelimit files
164184
if (problem.path / ".timelimit").is_file():
@@ -168,9 +188,6 @@ def __init__(
168188
"domjudge-problem.ini is DEPRECATED. Use limits.time_limit if you want to set a timelimit."
169189
)
170190

171-
self.time_limit: float = time_limit or 1.0
172-
self.time_limit_is_default: bool = time_limit is None
173-
174191
check_unknown_keys(yaml_data, "limits")
175192

176193
# Override limmits by command line arguments.
@@ -233,18 +250,23 @@ def __init__(
233250
self.uuid: str = parse_setting(yaml_data, "uuid", "")
234251
self.version: str = parse_setting(yaml_data, "version", "")
235252
self.credits: ProblemCredits = ProblemCredits(yaml_data, self)
236-
self.source: ProblemSources = ProblemSources(yaml_data, self)
253+
self.source: ProblemSources = ProblemSources(yaml_data)
237254
self.license: str = parse_setting(yaml_data, "license", "unknown")
238-
self.rights_owner: str = parse_setting(yaml_data, "rights_owner", "")
255+
self.rights_owner: Optional[str] = parse_optional_setting(yaml_data, "rights_owner", str)
239256
# Not implemented in BAPCtools. Should be a date, but we don't do anything with this anyway.
240-
self.embargo_until: str = parse_setting(yaml_data, "embargo-until", "")
257+
self.embargo_until: Optional[datetime.date] = parse_optional_setting(
258+
yaml_data,
259+
"embargo_until",
260+
# Note that datetime.datetime is also valid, as subclass of datetime.date
261+
datetime.date,
262+
)
241263
self.limits = ProblemLimits(parse_setting(yaml_data, "limits", {}), problem, self)
242264

243265
parse_deprecated_setting(
244266
yaml_data, "validator_flags", "output_validator_args' in 'testdata.yaml"
245267
)
246268

247-
self.keywords: str = parse_setting(yaml_data, "keywords", "")
269+
self.keywords: list[str] = parse_optional_list_setting(yaml_data, "keywords", str)
248270
# Not implemented in BAPCtools. We always test all languges in langauges.yaml.
249271
self.languages: list[str] = parse_optional_list_setting(yaml_data, "languages", str)
250272

@@ -271,13 +293,6 @@ def __init__(
271293
warn(f"invalid license: {self.license}")
272294
self.license = "unknown"
273295

274-
# Check that limits.validation_passes exists if and only if the problem is multi-pass
275-
has_validation_passes = self.limits.validation_passes is not None
276-
if self.multi_pass and not has_validation_passes:
277-
self.limits.validation_passes = 2
278-
if not self.multi_pass and has_validation_passes:
279-
warn("limit: validation_passes is only used for multi_pass problems. SKIPPED.")
280-
281296

282297
# A problem.
283298
class Problem:

bin/util.py

+10-2
Original file line numberDiff line numberDiff line change
@@ -813,9 +813,15 @@ def parse_optional_setting(yaml_data: dict[str, Any], key: str, t: type[T]) -> O
813813
return None
814814

815815

816-
def parse_setting(yaml_data: dict[str, Any], key: str, default: T) -> T:
816+
def parse_setting(
817+
yaml_data: dict[str, Any], key: str, default: T, constraint: Optional[str] = None
818+
) -> T:
817819
value = parse_optional_setting(yaml_data, key, type(default))
818-
return default if value is None else value
820+
result = default if value is None else value
821+
if constraint and not eval(f"{result} {constraint}"):
822+
warn(f"value for '{key}' in problem.yaml should be {constraint} but is {value}. SKIPPED.")
823+
return default
824+
return result
819825

820826

821827
def parse_optional_list_setting(yaml_data: dict[str, Any], key: str, t: type[T]) -> list[T]:
@@ -829,6 +835,8 @@ def parse_optional_list_setting(yaml_data: dict[str, Any], key: str, t: type[T])
829835
f"some values for key '{key}' in problem.yaml do not have type {t.__name__}. SKIPPED."
830836
)
831837
return []
838+
if not value:
839+
warn(f"value for '{key}' in problem.yaml should not be an empty list.")
832840
return value
833841
warn(f"incompatible value for key '{key}' in problem.yaml. SKIPPED.")
834842
return []

test/yaml/problem/invalid.yaml

+102
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,20 @@ yaml:
2424
mumbo: jumbo
2525
warn: "found unknown problem.yaml key: mumbo in `limits.time_multipliers`"
2626

27+
---
28+
# UUID
29+
yaml:
30+
problem_format_version: 2023-07-draft
31+
name: Invalid UUID, too short
32+
uuid: 12345678-abcd
33+
warn: "invalid uuid: 12345678-abcd"
34+
---
35+
yaml:
36+
problem_format_version: 2023-07-draft
37+
name: Invalid UUID, not hexadecimal
38+
uuid: 12345678-abcd-efgh-ijkl-12345678
39+
warn: "invalid uuid: 12345678-abcd-efgh-ijkl-12345678"
40+
2741
---
2842
# Name
2943
yaml:
@@ -85,3 +99,91 @@ yaml:
8599
name: Incorrect type (dict)
86100
type: 42
87101
fatal: "problem.yaml: 'type' must be a string or a sequence"
102+
103+
---
104+
# Limits
105+
yaml:
106+
problem_format_version: 2023-07-draft
107+
name: Negative time limit
108+
limits:
109+
time_limit: -1
110+
warn: "value for 'time_limit' in problem.yaml should be > 0 but is -1.0. SKIPPED."
111+
---
112+
yaml:
113+
problem_format_version: 2023-07-draft
114+
name: Time multiplier < 1
115+
limits:
116+
time_multipliers:
117+
ac_to_time_limit: 0.9
118+
warn: "value for 'ac_to_time_limit' in problem.yaml should be >= 1 but is 0.9. SKIPPED."
119+
---
120+
yaml:
121+
problem_format_version: 2023-07-draft
122+
name: Only one pass for multi-pass
123+
type: multi-pass
124+
limits:
125+
validation_passes: 1
126+
warn: "value for 'validation_passes' in problem.yaml should be >= 2 but is 1. SKIPPED."
127+
---
128+
yaml:
129+
problem_format_version: 2023-07-draft
130+
name: Fractional passes for multi-pass
131+
type: multi-pass
132+
limits:
133+
validation_passes: 2.5
134+
warn: "incompatible value for key 'validation_passes' in problem.yaml. SKIPPED."
135+
---
136+
yaml:
137+
problem_format_version: 2023-07-draft
138+
name: validation_passes for non-multi-pass problem
139+
limits:
140+
validation_passes: 3
141+
warn: "limit: validation_passes is only used for multi-pass problems. SKIPPED."
142+
143+
---
144+
# Empty list
145+
yaml:
146+
problem_format_version: 2023-07-draft
147+
name: pass-fail type from empty type
148+
type: []
149+
warn: "value for 'type' in problem.yaml should not be an empty list."
150+
---
151+
yaml:
152+
problem_format_version: 2023-07-draft
153+
name: Empty list
154+
keywords: []
155+
warn: "value for 'keywords' in problem.yaml should not be an empty list."
156+
157+
---
158+
# Credits
159+
yaml:
160+
problem_format_version: 2023-07-draft
161+
name: Cannot specify multiple authors in credits
162+
credits:
163+
- name: Alice
164+
- name: Audrey Authorson
165+
166+
warn: "incompatible value for key 'credits' in problem.yaml. SKIPPED."
167+
168+
---
169+
# Source
170+
yaml:
171+
problem_format_version: 2023-07-draft
172+
name: Source must have a name
173+
source:
174+
- url: https://2024.nwerc.example/contest
175+
warn: "problem.yaml: 'name' is required in source"
176+
177+
---
178+
# Embargo
179+
yaml:
180+
problem_format_version: 2023-07-draft
181+
name: Embargo is not a date
182+
embargo_until: not a date
183+
warn: "incompatible value for key 'embargo_until' in problem.yaml. SKIPPED."
184+
#---
185+
#yaml:
186+
# problem_format_version: 2023-07-draft
187+
# name: Embargo date does not exist
188+
# embargo_until: 2025-02-29
189+
# Note that this cannot be tested in this way, because the YAML parser already throws an error.

0 commit comments

Comments
 (0)