-
Notifications
You must be signed in to change notification settings - Fork 78
Implement new system for defining problem-formats #286
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
Co-authored-by: Zazmuz <rasmuswario@gmail.com>
gkreitz
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the PR. Overall, I like the direction you're heading in here. The PR description is excellent, and helped with navigating the code. Thanks for that!
I think a major issue is that you seem to have missed declaring depends_on for several methods where I think you do in fact depend on other components. I'm guessing this went unnoticed as Problem.get silently returns None when this happens (plus, I guess it's "lucky" that config sorts early :)). Unless I'm missing something, this will be a bit tricky to fix, as I think some of the dependencies are circular.
I also added a few more nit-picky comments (typically accessing via .classes, and/or using hard-coded strings instead of Foo.PART_NAME), which are not blockers for merging, but I wrote them as I was working my way through the code (and while I wasn't sure I understood the PR), and left them in.
problemtools/verifyproblem.py
Outdated
| raise NotImplementedError('Every problem-part must override PART_NAME') | ||
| super().__init__(f"{problem.shortname}.{self.PART_NAME}") | ||
| self.problem = problem | ||
| self.problem.data[self.PART_NAME] = {} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The ownership of problem.data gets pretty messy when ProblemPart classes take on the responsibility for initializing like this (and even more so when directly writing into the dict in set_prop).
I would prefer if Problem didn't expose data as a dict, but instead took care of initialization, and exposed writing values as a function. Did you consider that approach and rule it out?
If keeping the current version, it would likely be wise to assert that self.PART_NAME not in self.problem.data before doing the assignment, to avoid overwriting data in case multiple parts accidentally use the same PART_NAME
problemtools/verifyproblem.py
Outdated
| self._problem.classes[InputValidators.PART_NAME].validate(self) | ||
| anssize = os.path.getsize(self.ansfile) / 1024.0 / 1024.0 | ||
| outputlim = self._problem.config.get('limits')['output'] | ||
| outputlim = self._problem.classes['config'].get('limits')['output'] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
'config' here should probably be ProblemConfig.PART_NAME?
problemtools/verifyproblem.py
Outdated
| if self._problem.is_interactive: | ||
| res_high = self._problem.output_validators.validate_interactive(self, sub, timelim_high, self._problem.submissions) | ||
| if self._problem.get(ProblemTestCases, 'is_interactive'): | ||
| res_high = self._problem.classes[OutputValidators.PART_NAME].validate_interactive(self, sub, timelim_high, self._problem.classes['submissions']) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, now I'm starting to assume I'm missing some subtle detail. Why 'submissions' rather than a PART_NAME (related to the comment above)?
problemtools/verifyproblem.py
Outdated
| status, runtime = sub.run(infile=self.infile, outfile=outfile, errfile=errfile, | ||
| timelim=timelim_high+1, | ||
| memlim=self._problem.config.get('limits')['memory'], work_dir=sub.path) | ||
| memlim=self._problem.classes['config'].get('limits')['memory'], work_dir=sub.path) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
and 'config' here (I'll stop pointing these out).
problemtools/verifyproblem.py
Outdated
| self._check_res = True | ||
|
|
||
| if not self.statements: | ||
| self.error(f'No problem statements found (expected file of one of following forms in folder problem_statement/: {', '.join(f'problem.{ext}, problem.[a-z][a-z].{ext}' for ext in self.EXTENSIONS)}') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a syntax error in earlier python versions (which is why the CI tests fail), so you'll want to rewrite this.
problemtools/verifyproblem.py
Outdated
| f.close() | ||
| rejected = False | ||
| for testcase in self._problem.testdata.get_all_testcases(): | ||
| for testcase in self.problem.get(ProblemTestCases, 'root_group').get_all_testcases(): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This also needs to be added as a depends_on
problemtools/verifyproblem.py
Outdated
| with Runner(self._problem, sub, context, timelim, timelim_low, timelim_high) as runner: | ||
| result, result_low, result_high = self._problem.testdata.run_submission(sub, runner, context) | ||
| with Runner(self.problem, sub, context, timelim, timelim_low, timelim_high) as runner: | ||
| result, result_low, result_high = self.problem.get(ProblemTestCases, 'root_group').run_submission(sub, runner, context) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like there are missing depends_on here too (ProblemTestCases , OutputValidators, ProblemConfig if I read the code in this class correctly).
problemtools/verifyproblem.py
Outdated
| def get(self, part, key, default=None) -> Any: | ||
| if isinstance(part, type) and issubclass(part, ProblemPart): | ||
| part = part.PART_NAME | ||
| if part not in self.data: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This feels like a footgun to me. Do you ever use default, I don't think I spotted any calls with it? It feels extremely error prone that calling get silently returns None if part not in self.data (i.e., that part was either a typo, or wasn't initialized yet).
I would prefer if trying to access data from an uninitialized part is always an error (in problemtools, not the problem we're testing :)). If you really need this functionality, I think it makes sense to force the caller to explicitly toggle it on (e.g,. get(self, part, key, default=None, crash_if_part_uninitialized=True))
problemtools/verifyproblem.py
Outdated
| for cl in self.aspects: | ||
| if issubclass(cl, dependency): | ||
| init(cl) | ||
| break |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This feels a bit scary. I would love if you either initialize all classes which are subclasses of dependency, or add a check ensuring that there is only a unique class in self.aspects which is a subclass of dependency.
| else: | ||
| raise NotImplementedError(f'Part "{_class.PART_NAME}" depends on part "{dependency.PART_NAME}" which is not included in problem-format') | ||
| self.debug(f'Initializing {_class.PART_NAME} ({_class})') | ||
| self.classes[_class.PART_NAME] = _class(self) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would make sense to me if you did assert _class.PART_NAME not in self.data and then self.data[_class.PART_NAME] = {} just before this line (following up on my earlier comment re ownership of problem.data).
Changes made since last timeInformation channel between problem-partsFirst of all, if an invalid problem-part is indexed, this will now give an assertion-error. Instead of having an extra layer of key indexing so doing Problem.get(part, key), the indexing is now instead Problem.get(part) and this will return a dictionary so keys can be handled more ergonomically. The Renamed depends_on() to setup_dependencies()I noticed you had the impression that there were dependencies between parts when the data-access happened through for example the Initializing only one subclass of dependencyThe code was changed to make sure exactly one subclass was initialized when initializing dependency, and an additional assertion was added to make sure no two classes had the same PART_NAME when being initialized. Indexing with stringFixed all the indexing to be done with the constants instead of with the strings (I think), and changed all the Nested f-stringThe nested f-string was changed into to two statements which should hopefully make the tests pass now. Changed testcase_by_infile to be a class-parameter of ProblemTestCasesBecause this is changed as a part of the testcases being ran, this should not be a part of the Problem.get data. Therefore I added is as an instance-variable to ProblemTestCases and accessed it through |
gkreitz
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the quick fixes and explanation!
I'm a bit unsure why the unit tests blow up in the same way as previously, they still seem to run the old github action. I fixed the tests in #288, can you try rebase:ing this PR?
I added two minor comments. Neither is a blocker.
Firstly, it seems like 50-50 whether you use .get(key) or [key] when accessing the dict returned by problem.get(...). I'm not sure if there's some subtle pattern I'm not seeing. Both work just fine, but as a reader of the code, using two different styles gets a tad confusing.
Secondly, there was a minor bug in a check, but thankfully you added defense in depth there, making the bug harmless (it should still be fixed, but it's not a blocker).
I'm setting approve now as this looks good enough, but I would love to see unit tests working. Can you try to rebase (and also delete the buggy break), and then we can merge this.
problemtools/verifyproblem.py
Outdated
| self.config['on_reject'] = 'continue' | ||
|
|
||
| if self._problem.config.get('type') == 'pass-fail': | ||
| if self._problem.get(ProblemConfig).get('type') == 'pass-fail': |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit-pick: this should probably have the same syntax as other accesses to config data (['type']), unless there's something subtle going on here.
| cnt += 1 | ||
| break | ||
| if cnt != 1: | ||
| raise NotImplementedError(f'Part "{_class.PART_NAME}" depends on part "{dependency.PART_NAME}" which showed up {cnt} times in problem-format (should have showed up exactly once)') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This check looks broken, as you didn't remove the break above (it will only detect 0 vs. 1, but won't notice if it appears multiple times).
As you added the assert below, we will still blow up if something occurs multiple times, so this bug is mostly harmless.
The old system for working with the different aspects of verifying a problem were not flexible enough to use for multiple formats, so a new system has been developed to make this more flexible.
Overview
Description of new system
Definition of a problemformat
The system for describing a problem-format works by providing a dictionary of categories to classes (types), similar to how "part_mapping" worked previously. For example for the legacy configuration right now we have:
{ 'config': [ProblemConfig], 'statement': [ProblemStatementLegacy, Attachments], 'validators': [InputValidators, OutputValidators], 'graders': [Graders], 'data': [ProblemTestCases], 'submissions': [Submissions], }This means we have the validation steps 'config', 'statement', 'validators', 'graders', 'data', and 'submissions'. These can be included via the commandline-option -p like before. The list of classes are which classes will be checked for each of these parts.
class ProblemPart
This is possible to do via new data that is defined in ProblemPart for each part that can be included. Each problem-part will have to override a class-variable
PART_NAMEwhich is a string representing the name of the part. There is a new system for communicating data between different problem-parts through the problem.The problem has a dictionary for each problem-part where properties can be set by each ProblemPart subclass. This data can then be accessed through for example
self.problem.get('statement', 'config'). To set a property for the problem-part you can write for exampleself.set_prop('foo', 'bar'), which would set the property 'foo' to 'bar'.It is also possible to access
problem.classes['statement']but the intention is to try to avoid this whenever possible, and rely only on the problem-part-properties instead. Hopefully we will be able to find a way to convert the current classes to follow this in future PR's. The thought-process is that these properties will be the only communication-channel between different parts of the problem to simplify the interactions.Since some parts require information from other parts in order to be initialized, a system for keeping track of initialization-dependencies was implemented. In each ProblemPart subclass, the method
depends_on() -> set[type]is possible to be overridden. For every class that is returned from this method, the problem-class will make sure that a subclass of that class is initialized before this one. For example in the old code, ProblemConfig required some information from ProblemStatement in order to be initialized so ProblemConfig will define:The Problem-class will then ensure that when setting up all the classes, it will resolve all dependencies to the classes before initializing them.
class Problem
Surprisingly little was changed for the Problem-class. The general logic was abstracted a bit to allow for multiple problem-formats, but the overall structure is similar to how it was previously. The Problem class will now require a problem-format in its constructor.
ProblemTestCases
The TestCaseGroup class was difficult to use with the new system since it recursively contains copies of itself, so a new class ProblemTestCases was added as a kind of wrapper around the TestCaseGroup class. It provides a decent example of a ProblemPart-subclass can look since it contains a dependency, is pretty small and uses the new communication-system with the Problem class.