diff --git a/python3_contest_problem/__tester.py b/python3_contest_problem/__tester.py
new file mode 100644
index 0000000..78a1dd2
--- /dev/null
+++ b/python3_contest_problem/__tester.py
@@ -0,0 +1,347 @@
+"""The generic (multi-language) main testing class that does all the work - trial compile, style checks,
+ run and grade.
+"""
+from __resulttable import ResultTable
+import html
+import os
+import re
+import __languagetask as languagetask
+import base64
+
+
+# Values of QUESTION.precheck field
+PRECHECK_DISABLED = 0
+PRECHECK_EMPTY = 1
+PRECHECK_EXAMPLES = 2
+PRECHECK_SELECTED = 3
+PRECHECK_ALL = 4
+
+# Values of testtype
+TYPE_NORMAL = 0
+TYPE_PRECHECKONLY = 1
+TYPE_BOTH = 2
+
+# Global message for when a test-suite timeout occurs
+TIMEOUT_MESSAGE = """A timeout occurred when running the whole test suite as a single program.
+This is usually due to an endless loop in your code but can also arise if your code is very inefficient
+and the accumulated time over all tests is excessive. Please ask a tutor or your lecturer if you need help
+with making your program more efficient."""
+
+
+def get_jpeg_b64(filename):
+ """Return the contents of the given file (assumed to be jpeg) as a base64
+ encoded string in utf-8.
+ """
+ with open(filename, 'br') as fin:
+ contents = fin.read()
+
+ return base64.b64encode(contents).decode('utf8')
+
+
+class Tester:
+ def __init__(self, params, testcases):
+ """Initialise the instance, given the test of template and global parameters plus
+ all the testcases. Parameters required by this base class and all subclasses are:
+ 'STUDENT_ANSWER': code submitted by the student
+ 'SEPARATOR': the string to be used to separate tests in the output
+ 'ALL_OR_NOTHING: true if grading is all-or-nothing
+ 'stdinfromextra': true if the test-case 'extra' field is to be used for
+ standard input rather than the usual stdin field
+ 'runtestssingly': true to force a separate run for each test case
+ 'stdinfromextra': true if the extra field is used for standard input (legacy use only)
+ 'testisbash': true if tests are bash command line(s) rather than the default direct execution
+ of the compiled program. This can be used to supply command line arguments.
+
+ """
+ self.student_answer = self.clean(params['STUDENT_ANSWER'])
+ self.separator = params['SEPARATOR']
+ self.all_or_nothing = params['ALL_OR_NOTHING']
+ self.params = params
+ self.testcases = self.filter_tests(testcases)
+ self.result_table = ResultTable(params)
+ self.result_table.set_header(self.testcases)
+
+ # It is assumed that in general subclasses will prefix student code by a prelude and
+ # postfix it by a postlude.
+ self.prelude = ''
+ self.prelude_length = 0
+ self.postlude = ''
+
+ self.task = None # SUBCLASS MUST DEFINE THIS
+
+ def filter_tests(self, testcases):
+ """Return the relevant subset of the question's testcases.
+ This will be all testcases not marked precheck-only if it's not a precheck or all testcases if it is a
+ precheck and the question precheck is set to "All", or the appropriate subset in all other cases.
+ """
+ if not self.params['IS_PRECHECK']:
+ return [test for test in testcases if test.testtype != TYPE_PRECHECKONLY]
+ elif self.params['QUESTION_PRECHECK'] == PRECHECK_ALL:
+ return testcases
+ elif self.params['QUESTION_PRECHECK'] == PRECHECK_EMPTY:
+ return []
+ elif self.params['QUESTION_PRECHECK'] == PRECHECK_EXAMPLES:
+ return [test for test in testcases if test.useasexample]
+ elif self.params['QUESTION_PRECHECK'] == PRECHECK_SELECTED:
+ return [test for test in testcases if test.testtype in [TYPE_PRECHECKONLY, TYPE_BOTH]]
+
+ def style_errors(self):
+ """Return a list of all the style errors. Implementation is language dependent.
+ Default is no style checking.
+ """
+ return []
+
+ def single_program_build_possible(self):
+ """Return true if and only if the current configuration permits a single program to be
+ built and tried containing all tests. It should be true for write-a-program questions and
+ conditionally true for other types of questions that allow a "combinator" approach,
+ dependent on the presence of stdins in tests and other such conditions.
+ """
+ raise NotImplementedError("Tester must have a single_program_build_possible method")
+
+ def adjust_error_line_nums(self, error):
+ """Given a runtime error message, adjust it as may be required by the
+ language, e.g. adjusting line numbers
+ """
+ raise NotImplementedError("Tester must have an adjust_error_line_nums method")
+
+ def single_run_possible(self):
+ """Return true if a single program has been built and it is possible to use that in a single run
+ with all tests.
+ """
+ return (self.task.executable_built
+ and not self.params['runtestssingly']
+ and not self.result_table.has_stdins
+ and not self.params['testisbash'])
+
+ def make_test_postlude(self, testcases):
+ """Return the postlude testing code containing all the testcode from
+ the given list of testcases (which may be the full set or a singleton list).
+ A separator must be printed between testcase outputs."""
+ raise NotImplementedError("Tester must have a make_test_postlude method")
+
+ def trial_compile(self):
+ """This function is the first check on the syntactic correctness of the submitted code.
+ It is called before any style checks are done. For compiled languages it should generally
+ call the standard language compiler on the student submitted code with any required prelude
+ added and, if possible, all tests included. CompileError should be raised if the compile fails,
+ which will abort all further testing.
+ If possible a complete ready-to-run executable should be built as well; if this succeeds, the
+ LanguageTasks 'executable_built' attribute should be set. This should be possible for write-a-program
+ questions or for write-a-function questions when there is no stdin data in any of the tests.
+
+ Interpreted languages should perform what syntax checks are possible using the standard language tools.
+ If those checks succeeded, they should also attempt to construct a source program that incorporates all
+ the different tests (the old "combinator" approach) and ensure the task's 'executable_built' attribute
+ is True.
+
+ The following implementation is sufficient for standard compiled languages like C, C++, Java. It
+ may need overriding for other languages.
+ """
+ if self.single_program_build_possible():
+ self.setup_for_test_runs(self.testcases)
+ make_executable = True
+ else:
+ self.postlude = ''
+ self.task.set_code(self.prelude + self.student_answer, self.prelude_length)
+ make_executable = False
+
+ self.task.compile(make_executable) # Could raise CompileError
+
+ def setup_for_test_runs(self, tests):
+ """Set the code and prelude length as appropriate for a run with all the given tests. May be called with
+ just a singleton list for tests if single_program_build_possible has returned false or if testing with
+ multiple tests has given exceptions.
+ This implementation may need to be overridden, e.g. if the student code should follow the test code, as
+ say in Matlab scripts.
+ """
+ self.postlude = self.make_test_postlude(tests)
+ self.task.set_code(self.prelude + self.student_answer + self.postlude, self.prelude_length)
+
+ def run_all_tests(self):
+ """Run all the tests, leaving self.ResultTable object containing all test results.
+ Can raise CompileError or RunError if things break.
+ If any runtime errors occur on the full test, drop back to running tests singly.
+ """
+ done = False
+ if self.single_run_possible():
+ # We have an executable ready to go, with no stdins or other show stoppers
+ output, error = self.task.run_code()
+ output = output.rstrip() + '\n'
+ error = error.strip() + '\n'
+
+ # Generate a result table using all available test data.
+ results = output.split(self.separator + '\n')
+ errors = error.split(self.separator + '\n')
+ if len(results) == len(errors):
+ merged_results = []
+ for result, error in zip(results, errors):
+ result = result.rstrip() + '\n'
+ if error.strip():
+ adjusted_error = self.adjust_error_line_nums(error.rstrip())
+ result += '\n*** RUN ERROR ***\n' + adjusted_error
+ merged_results.append(result)
+
+ missed_tests = len(self.testcases) - len(merged_results)
+
+ for test, output in zip(self.testcases, merged_results):
+ self.result_table.add_row(test, output)
+
+ self.result_table.tests_missed(missed_tests)
+ if self.task.timed_out:
+ self.result_table.record_global_error(TIMEOUT_MESSAGE)
+ done = True
+
+ if not done:
+ # Something broke. We will need to run each test case separately
+ self.task.executable_built = False
+ self.result_table.reset()
+
+ if not done:
+ # If a single run isn't appropriate, do a separate run for each test case.
+ build_each_test = not self.task.executable_built
+ for i_test, test in enumerate(self.testcases):
+ if build_each_test:
+ self.setup_for_test_runs([test])
+ self.task.compile(True)
+ standard_input = test.extra if self.params['stdinfromextra'] else test.stdin
+ if self.params['testisbash']:
+ output, error = self.task.run_code(standard_input, test.testcode)
+ else:
+ output, error = self.task.run_code(standard_input)
+ adjusted_error = self.adjust_error_line_nums(error.rstrip())
+ self.result_table.add_row(test, output, adjusted_error)
+ if error and self.params['abortonerror']:
+ self.result_table.tests_missed(len(self.testcases) - i_test - 1)
+ break
+
+ def compile_and_run(self):
+ """Phase one of the test operation: do a trial compile and then, if all is well and it's not a precheck,
+ continue on to run all tests.
+ Return a tuple mark, errors where mark is a fraction in 0 - 1 and errors is a list of all the errors.
+ self.test_results contains all the test details.
+ """
+ mark = 0
+ errors = []
+
+ # Do a trial compile, then a style check. If all is well, run the code
+ try:
+ self.trial_compile()
+
+ if not self.params['nostylechecks']:
+ errors = self.style_errors()
+ if not errors:
+ if self.params['IS_PRECHECK'] and self.params['QUESTION_PRECHECK'] <= 1:
+ mark = 1
+ else:
+ self.run_all_tests()
+ max_mark = sum(test.mark for test in self.testcases)
+ mark = self.result_table.get_mark() / max_mark # Fractional mark 0 - 1
+ except languagetask.CompileError as err:
+ adjusted_error = self.adjust_error_line_nums(str(err).rstrip())
+ errors.append("COMPILE ERROR\n" + adjusted_error)
+ except languagetask.RunError as err:
+ adjusted_error = self.adjust_error_line_nums(str(err).rstrip())
+ errors.append('RUN ERROR\n' + adjusted_error)
+ return mark, errors
+
+ def prerun_hook(self):
+ """A hook for subclasses to do initial setup or code hacks etc
+ Returns a list of errors, to which other errors are appended
+ """
+ return []
+
+ def get_all_images_html(self):
+ r"""Search the current directory for images named _image.*(Expected|Got)(\d+).png.
+ For each such file construct an html img element with the data encoded
+ in a dataurl.
+ If we're running the sample answer, always return [] - images will be
+ picked up when we run the actual answer.
+ Returns a list of tuples (img_elements, column_name, row_number) where
+ column_name is either 'Expected' or 'Got', defining in which result table
+ column the image belongs and row number is the row (0-origin, excluding
+ the header row).
+ """
+ images = []
+ if self.params.get('running_sample_answer', False):
+ return []
+ if self.params['imagewidth'] is not None:
+ width_spec = " width={}".format(self.params['imagewidth'])
+ else:
+ width_spec = ""
+ files = sorted(os.listdir('.'))
+ for filename in files:
+ match = re.match(r'_image[^.]*\.(Expected|Got)\.(\d+).png', filename)
+ if match:
+ image_data = get_jpeg_b64(filename)
+ img_template = ''
+ img_html = img_template.format(width_spec, image_data)
+ column = match.group(1) # Name of column
+ row = int(match.group(2)) # 0-origin row number
+ images.append((img_html, column, row))
+ return images
+
+ def test_code(self):
+ """The "main program" for testing. Returns the test outcome, ready to be printed by json.dumps"""
+ errors = self.prerun_hook()
+ if errors:
+ mark = 0
+ else:
+ mark, errors = self.compile_and_run()
+
+ outcome = {"fraction": mark}
+
+ error_text = '\n'.join(errors)
+ # TODO - check if error line numbers are still being corrected in C and matlab
+ if self.params['IS_PRECHECK']:
+ if mark == 1:
+ prologue = "
Passed 🙂
" + else: + prologue = "Failed, as follows.
" + elif errors: + prologue = "{}
' + html.escape(message) + '' if message else '' diff --git a/python3_contest_problem/pytester.py b/python3_contest_problem/pytester.py new file mode 100644 index 0000000..ff3fe65 --- /dev/null +++ b/python3_contest_problem/pytester.py @@ -0,0 +1,279 @@ +"""The main python-program testing class that does all the work - style checks, + run and grade. A subclass of the generic tester. + Since each test can by run within the current instance of Python using + an exec, we avoid the usual complication of combinators by running + each test separately regardless of presence of stdin, testcode, etc. +""" +import __pytask as pytask +import re +from __tester import Tester +from __pystylechecker import StyleChecker +from random import randint + + +class PyTester(Tester): + def __init__(self, params, testcases): + """Initialise the instance, given the test of template and global parameters plus + all the testcases. Parameters relevant to this class are all those listed for the Tester class plus + 'extra' which takes the values 'pretest' or 'posttest' (the other possible value, 'stdin', has been + handled by the main template). + Additionally the support classes like stylechecker and pyparser need their + own params - q.v. + """ + super().__init__(params, testcases) # Most of the task is handed by the generic tester + + # Py-dependent attributes + self.task = pytask.PyTask(params) + self.prelude = '' + + if params['isfunction']: + if not self.has_docstring(): + self.prelude = '"""Dummy docstring for a function"""\n' + + if params['usesmatplotlib']: + self.prelude += '\n'.join([ + 'import os', + 'import matplotlib as _mpl', + '_mpl.use("Agg")', + 'from __plottools import print_plot_info', + ]) + '\n' + self.params['pylintoptions'].append("--disable=ungrouped-imports") + + if params['usesnumpy']: + self.prelude += 'import numpy as np\n' + self.params['pylintoptions'].append("--disable=unused-import,ungrouped-imports") + self.params['pylintoptions'].append("--extension-pkg-whitelist=numpy") + + for import_string in params['imports']: + if ' ' not in import_string: + self.prelude += 'import ' + import_string + '\n' + else: + self.prelude += import_string + '\n' + + if params['prelude'] != '': + self.prelude += '\n' + params['prelude'].rstrip() + '\n' + + try: + with open('_prefix.py') as prefix: + prefix_code = prefix.read() + self.prelude += prefix_code.rstrip() + '\n' + + except FileNotFoundError: + pass + + self.prelude_length = len(self.prelude.splitlines()) + if self.has_docstring() and self.prelude_length > 0: + # If we insert prelude in front of the docstring, pylint will + # give a missing docstring error. Our horrible hack solution is + # to insert an extra docstring at the start and turn off the + # resulting 'string statement has no effect' error. + self.prelude = '"""Dummy docstring for a function"""\n' + self.prelude + self.prelude_length += 1 + self.params['pylintoptions'].append("--disable=W0105") + self.style_checker = StyleChecker(self.prelude, self.params['STUDENT_ANSWER'], self.params) + + def has_docstring(self): + """True if the student answer has a docstring, which means that, + when stripped, it starts with a string literal. + """ + prog = self.params['STUDENT_ANSWER'].lstrip() + return prog.startswith('"') or prog.startswith("'") + + + def tweaked_warning(self, message): + """Improve the warning message by updating line numbers and replacing
: Xnnnn + """ + pattern = r'__source.py:(\d+): *\d+: *[A-Z]\d+: (.*)' + match = re.match(pattern, error) + if match: + return f"Line {match.group(1)}: {match.group(2)}" + else: + return error + + def main_hacks(self): + """Modify the code to be tested if params stripmain or stripmainifpresent' + are specified. Returns a list of errors encountered while so doing. + """ + errors = [] + if self.params['stripmain'] or self.params['stripmainifpresent']: + main_calls = self.style_checker.find_function_calls('main') + if self.params['stripmain'] and main_calls == []: + errors.append("No call to main() found") + else: + student_lines = self.student_answer.split('\n') + for (line, depth) in main_calls: + if depth == 0: + main_call = student_lines[line] + if not re.match(r' *main\(\)', main_call): + errors.append(f"Illegal call to main().\n" + + "main should not take any parameters and should not return anything.") + else: + student_lines[line] = main_call.replace( + "main", "pass # Disabled call to main") + else: + student_lines[line] += " # We've let you call main here." + self.params['STUDENT_ANSWER'] = self.student_answer = '\n'.join(student_lines) + '\n' + + return errors diff --git a/python3_deskcheck/junk.py b/python3_deskcheck/junk.py new file mode 100644 index 0000000..1a9249d --- /dev/null +++ b/python3_deskcheck/junk.py @@ -0,0 +1,2 @@ +for i in [1, 2, 3] + print(i) \ No newline at end of file diff --git a/python3_deskcheck/prototypeextra.html b/python3_deskcheck/prototypeextra.html index 411e05f..ee32d7e 100644 --- a/python3_deskcheck/prototypeextra.html +++ b/python3_deskcheck/prototypeextra.html @@ -10,6 +10,7 @@ + + + + +
+: Xnnnn
"""
- pattern = f'__source.py:(\d+): *\d+: *[A-Z]\d+: (.*)'
+ pattern = r'__source.py:(\d+): *\d+: *[A-Z]\d+: (.*)'
match = re.match(pattern, error)
if match:
return f"Line {match.group(1)}: {match.group(2)}"
@@ -260,8 +266,8 @@ def main_hacks(self):
for (line, depth) in main_calls:
if depth == 0:
main_call = student_lines[line]
- if not re.match(' *main\(\)', main_call):
- errors.append(f"Illegal call to main().\n" +
+ if not re.match(r' *main\(\)', main_call):
+ errors.append("Illegal call to main().\n" +
"main should not take any parameters and should not return anything.")
else:
student_lines[line] = main_call.replace(
diff --git a/python3_scratchpad/template.py b/python3_scratchpad/template.py
index b79d70e..9fa6653 100644
--- a/python3_scratchpad/template.py
+++ b/python3_scratchpad/template.py
@@ -7,25 +7,23 @@
from pytester import PyTester
-STANDARD_PYLINT_OPTIONS = ['--disable=trailing-whitespace,superfluous-parens,' +
- 'bad-continuation,min-public-methods,too-few-public-methods,star-args,' +
- 'unbalanced-tuple-unpacking,too-many-statements,' +
- 'consider-using-enumerate,simplifiable-if-statement,' +
- 'consider-iterating-dictionary,trailing-newlines,no-else-return,' +
- 'consider-using-dict-comprehension,' +
- 'len-as-condition,inconsistent-return-statements,consider-using-join,' +
- 'singleton-comparison,unused-variable,chained-comparison,no-else-break,' +
- 'consider-using-in,useless-object-inheritance,unnecessary-pass,' +
- 'reimported,wrong-import-order,wrong-import-position,ungrouped-imports,' +
- 'consider-using-set-comprehension,no-else-raise,' +
- 'unspecified-encoding,use-dict-literal,consider-using-with,' +
- 'duplicate-string-formatting-argument,consider-using-dict-items,' +
- 'consider-using-max-builtin,unnecessary-lambda,consider-using-with,' +
- 'consider-using-f-string,unspecified-encoding,use-dict-literal,' +
- 'use-a-generator',
- '--enable=C0326',
- '--good-names=i,j,k,n,s,c,_'
- ]
+STANDARD_PYLINT_OPTIONS = ['--disable=trailing-whitespace,superfluous-parens,' +
+ 'too-few-public-methods,consider-using-f-string,' +
+ 'unbalanced-tuple-unpacking,too-many-statements,' +
+ 'consider-using-enumerate,simplifiable-if-statement,' +
+ 'consider-iterating-dictionary,trailing-newlines,no-else-return,' +
+ 'consider-using-dict-comprehension,consider-using-generator,' +
+ 'len-as-condition,inconsistent-return-statements,consider-using-join,' +
+ 'singleton-comparison,unused-variable,chained-comparison,no-else-break,' +
+ 'consider-using-in,useless-object-inheritance,unnecessary-pass,' +
+ 'reimported,wrong-import-order,wrong-import-position,ungrouped-imports,' +
+ 'consider-using-set-comprehension,no-else-raise,unnecessary-lambda-assignment,' +
+ 'unspecified-encoding,use-dict-literal,,consider-using-with,' +
+ 'duplicate-string-formatting-argument,consider-using-dict-items,' +
+ 'consider-using-max-builtin,use-a-generator,unidiomatic-typecheck',
+ '--good-names=i,j,k,n,s,c,_'
+ ]
+
locale.setlocale(locale.LC_ALL, 'C.UTF-8')
@@ -48,6 +46,7 @@
'maxfunctionlength': 30,
'maxnumconstants': 4,
'maxoutputbytes': 10000,
+ 'maxprechecks': None,
'maxstringlength': 2000,
'norun': False,
'nostylechecks': False,
@@ -57,9 +56,10 @@
'prelude': '',
'proscribedbuiltins': ['exec', 'eval'],
'proscribedfunctions': [],
- 'proscribedconstructs': ["goto", "while_with_else"],
+ 'proscribedconstructs': ["goto"],
'proscribedsubstrings': [],
'pylintoptions': [],
+ 'pylintmatplotlib': False,
'requiredconstructs': [],
'requiredfunctiondefinitions': [],
'requiredfunctioncalls': [],
@@ -78,8 +78,11 @@
'importlib': {
'onlyallow': []
},
+ 'fileinput': {
+ 'onlyallow': []
+ },
'os': {
- 'disallow': ['system', '_exit', '_.*']
+ 'disallow': ['system', '_exit', '_.*', 'open', 'fdopen', 'listdir']
},
'subprocess': {
'onlyallow': []
@@ -143,6 +146,12 @@ def process_template_params():
else:
PARAMS[param_name] = default;
+ result_columns = """{{QUESTION.resultcolumns}}""".strip();
+ if result_columns == '':
+ PARAMS['resultcolumns'] = [['Test', 'testcode'], ['Input', 'stdin'], ['Expected', 'expected'], ['Got', 'got']]
+ else:
+ PARAMS['resultcolumns'] = json.loads(result_columns);
+
if PARAMS['extra'] == 'stdin':
PARAMS['stdinfromextra'] = True
if PARAMS['runextra']:
@@ -153,7 +162,10 @@ def process_template_params():
if PARAMS['allowglobals']:
PARAMS['pylintoptions'].append("--const-rgx='[a-zA-Z_][a-zA-Z0-9_]{2,30}$'")
if PARAMS['usesmatplotlib']:
- PARAMS['pylintoptions'].append("--disable=reimported,wrong-import-position,wrong-import-order,unused-import")
+ if PARAMS['pylintmatplotlib']:
+ PARAMS['pylintoptions'].append("--disable=reimported,wrong-import-position,wrong-import-order,unused-import")
+ else:
+ PARAMS['precheckers'] = []
if PARAMS['testisbash']:
print("testisbash is not implemented for Python")
@@ -190,8 +202,7 @@ def process_global_params():
"""Plug into the PARAMS variable all the "global" parameters from
the question and its answer (as distinct from the template parameters).
"""
- response = json.loads("""{{ STUDENT_ANSWER | e('py') }}""")
- PARAMS['STUDENT_ANSWER'] = response['answer_code'][0].rstrip() + '\n'
+ PARAMS['STUDENT_ANSWER'] = """{{ STUDENT_ANSWER | e('py') }}""".rstrip() + '\n'
PARAMS['SEPARATOR'] = "#Test table
+ Examples
+Answer
+
+
This is a skeleton rewrite of the SST question type in which the SST parameters that were previously set by template parameters are now set by a control panel within the question. Only the num_vars and num_rows parameters are implemented so far. All the others (input_width, output_width, return_width) are still to be done. And the control panel should be hidden from the student, visible only to the teacher.
+To create a new question of this type:
+Passed 🙂
" + else: + prologue = "Failed, as follows.
" + elif errors: + prologue = "{}
' + html.escape(message) + '' if message else '' diff --git a/python3_scratchpad_plus/pytester.py b/python3_scratchpad_plus/pytester.py new file mode 100644 index 0000000..1819302 --- /dev/null +++ b/python3_scratchpad_plus/pytester.py @@ -0,0 +1,255 @@ +"""The main python-program testing class that does all the work - style checks, + run and grade. A subclass of the generic tester. + Since each test can by run within the current instance of Python using + an exec, we avoid the usual complication of combinators by running + each test separately regardless of presence of stdin, testcode, etc. +""" +import __pytask as pytask +import re +from __tester import Tester +from __pystylechecker import StyleChecker + + +class PyTester(Tester): + def __init__(self, params, testcases): + """Initialise the instance, given the test of template and global parameters plus + all the testcases. Parameters relevant to this class are all those listed for the Tester class plus + 'extra' which takes the values 'pretest' or 'posttest' (the other possible value, 'stdin', has been + handled by the main template). + Additionally the support classes like stylechecker and pyparser need their + own params - q.v. + """ + super().__init__(params, testcases) # Most of the task is handed by the generic tester + + # Py-dependent attributes + self.task = pytask.PyTask(params) + self.prelude = '' + + if params['isfunction']: + if not self.has_docstring(): + self.prelude = '"""Dummy docstring for a function"""\n' + + if params['usesmatplotlib']: + self.prelude += '\n'.join([ + 'import os', + 'import matplotlib as _mpl', + '_mpl.use("Agg")', + 'from __plottools import print_plot_info', + ]) + '\n' + self.params['pylintoptions'].append("--disable=ungrouped-imports") + + if params['usesnumpy']: + self.prelude += 'import numpy as np\n' + self.params['pylintoptions'].append("--disable=unused-import,ungrouped-imports") + self.params['pylintoptions'].append("--extension-pkg-whitelist=numpy") + + for import_string in params['imports']: + if ' ' not in import_string: + self.prelude += 'import ' + import_string + '\n' + else: + self.prelude += import_string + '\n' + + if params['prelude'] != '': + self.prelude += '\n' + params['prelude'].rstrip() + '\n' + + try: + with open('_prefix.py') as prefix: + prefix_code = prefix.read() + self.prelude += prefix_code.rstrip() + '\n' + + except FileNotFoundError: + pass + + self.prelude_length = len(self.prelude.splitlines()) + if self.has_docstring() and self.prelude_length > 0: + # If we insert prelude in front of the docstring, pylint will + # give a missing docstring error. Our horrible hack solution is + # to insert an extra docstring at the start and turn off the + # resulting 'string statement has no effect' error. + self.prelude = '"""Dummy docstring for a function"""\n' + self.prelude + self.prelude_length += 1 + self.params['pylintoptions'].append("--disable=W0105") + self.style_checker = StyleChecker(self.prelude, self.params['STUDENT_ANSWER'], self.params) + + def has_docstring(self): + """True if the student answer has a docstring, which means that, + when stripped, it starts with a string literal. + """ + prog = self.params['STUDENT_ANSWER'].lstrip() + return prog.startswith('"') or prog.startswith("'") + + def style_errors(self): + """Return a list of all the style errors. Start with local tests and continue with pylint + only if there are no local errors. + """ + errors = [] + if self.params.get('localprechecks', True): + try: + errors += self.style_checker.local_errors() # Note: prelude not included so don't adjust line nums + except Exception as e: + errors += [str(e)] + else: + check_for_passive = (self.params['warnifpassiveoutput'] and self.params['isfunction']) + if check_for_passive and self.passive_output(): + errors.append("Your code was not expected to generate any output " + + "when executed stand-alone.\nDid you accidentally include " + + "your test code?") + + if len(errors) == 0 or self.params.get('forcepylint', False): + # Run precheckers (pylint, mypy) + try: + # Style-check the program without any test cases or other postlude added + errors += self.style_checker.style_errors() + except Exception as e: + error_text = '*** Unexpected error while running precheckers. Please report ***\n' + str(e) + errors += [error_text] + errors = [self.simplify_error(self.adjust_error_line_nums(error)) for error in errors] + errors = [error for error in errors if not error.startswith('************* Module')] + + errors = [error.replace('
: Xnnnn
+ """
+ pattern = r'__source.py:(\d+): *\d+: *[A-Z]\d+: (.*)'
+ match = re.match(pattern, error)
+ if match:
+ return f"Line {match.group(1)}: {match.group(2)}"
+ else:
+ return error
+
+ def main_hacks(self):
+ """Modify the code to be tested if params stripmain or stripmainifpresent'
+ are specified. Returns a list of errors encountered while so doing.
+ """
+ errors = []
+ if self.params['stripmain'] or self.params['stripmainifpresent']:
+ main_calls = self.style_checker.find_function_calls('main')
+ if self.params['stripmain'] and main_calls == []:
+ errors.append("No call to main() found")
+ else:
+ student_lines = self.student_answer.split('\n')
+ for (line, depth) in main_calls:
+ if depth == 0:
+ main_call = student_lines[line]
+ if not re.match(r' *main\(\)', main_call):
+ errors.append(f"Illegal call to main().\n" +
+ "main should not take any parameters and should not return anything.")
+ else:
+ student_lines[line] = main_call.replace(
+ "main", "pass # Disabled call to main")
+ else:
+ student_lines[line] += " # We've let you call main here."
+ self.params['STUDENT_ANSWER'] = self.student_answer = '\n'.join(student_lines) + '\n'
+
+ return errors
diff --git a/python3_stage1/__pystylechecker (1).py b/python3_stage1/__pystylechecker (1).py
new file mode 100644
index 0000000..2c296e5
--- /dev/null
+++ b/python3_stage1/__pystylechecker (1).py
@@ -0,0 +1,456 @@
+"""All the style checking code for Python3.
+ This special version disallows any global code
+ except function defs and global constants.
+"""
+
+from io import BytesIO
+import os
+import subprocess
+import ast
+import tokenize
+import token
+import re
+import shutil
+from collections import defaultdict
+
+class StyleChecker:
+ def __init__(self, prelude, student_answer, params):
+ self.prelude = prelude
+ self.student_answer = student_answer
+ self.params = params
+ self.function_call_map = None
+ self._tree = None
+
+ @property
+ def tree(self):
+ if self._tree is None:
+ self._tree = ast.parse(self.student_answer)
+ return self._tree
+
+ def style_errors(self):
+ """Return a list of errors from local style checks plus pylint and/or mypy
+ """
+ errors = []
+ source = open('__source.py', 'w', encoding="utf-8")
+ code_to_check = self.prelude + self.student_answer
+ prelude_len = len(self.prelude.splitlines())
+ source.write(code_to_check)
+ source.close()
+ env = os.environ.copy()
+ env['HOME'] = os.getcwd()
+ pylint_opts = self.params.get('pylintoptions',[])
+ precheckers = self.params.get('precheckers', ['pylint'])
+ result = ''
+
+ if 'pylint' in precheckers:
+ try: # Run pylint
+ cmd = 'python3.9 -m pylint ' + ' '.join(pylint_opts) + ' __source.py'
+ result = subprocess.check_output(cmd,
+ stderr=subprocess.STDOUT,
+ universal_newlines=True,
+ env=env,
+ shell=True)
+ except Exception as e:
+ result = e.output
+
+ else:
+ # (mct63) Abort if there are any comments containing 'pylint:'.
+ try:
+ tokenizer = tokenize.tokenize(BytesIO(self.student_answer.encode('utf-8')).readline)
+ for token_type, token_text, *_ in tokenizer:
+ if token_type == tokenize.COMMENT and 'pylint:' in token_text:
+ errors.append("Comments can not include 'pylint:'")
+ break
+
+ except Exception:
+ errors.append("Something went wrong while parsing comments. Report this.")
+
+ if "Using config file" in result:
+ result = '\n'.join(result.splitlines()[1:]).split()
+
+ if result == '' and 'mypy' in precheckers:
+ code_to_check = 'from typing import List as list, Dict as dict, Tuple as tuple, Set as set, Any\n' + code_to_check
+ with open('__source2.py', 'w', encoding='utf-8') as outfile:
+ outfile.write(code_to_check)
+ cmd = 'python3.8 -m mypy --no-error-summary --no-strict-optional __source2.py'
+ try: # Run mypy
+ subprocess.check_output(cmd, # Raises an exception if there are errors
+ stderr=subprocess.STDOUT,
+ universal_newlines=True,
+ env=env,
+ shell=True)
+ except Exception as e:
+ result = e.output
+ line_num_fix = lambda match: "Line " + str(int(match[1]) - 1 - prelude_len) + match[2]
+ result = re.sub(r'__source2.py:(\d+)(.*)', line_num_fix, result)
+
+ if result == '' and self.params.get('requiretypehints', False):
+ bad_funcs = self.check_type_hints()
+ for fun in bad_funcs:
+ result += f"Function '{fun}' does not have correct type hints\n"
+
+ if result:
+ errors = result.strip().splitlines()
+ errors.append("Sorry, but your code doesn't pass the style checks.")
+
+ return errors
+
+ def local_errors(self):
+ """Perform various local checks as specified by the current set of
+ template parameters.
+ """
+ errors = []
+
+ for banned in self.params.get('proscribedsubstrings', []):
+ if banned in self.student_answer:
+ errors.append(f"The string '{banned}' is not permitted anywhere in your code.")
+
+ if self.params.get('banglobalcode', True):
+ errors += self.find_global_code()
+
+ if not self.params.get('allownestedfunctions', True):
+ # Except for legacy questions or where explicitly allowed, nested functions are banned
+ nested_funcs = self.find_nested_functions()
+ for func in nested_funcs:
+ errors.append("Function '{}' is defined inside another function".format(func))
+
+ max_length = self.params['maxfunctionlength']
+ bad_funcs = self.find_too_long_funcs(max_length)
+ for func, count in bad_funcs:
+ errors.append("Function '{}' is too long\n({} statements, max is {})"
+ "".format(func, count, max_length))
+
+ bad_used = self.find_illegal_functions()
+ for name in bad_used:
+ errors.append("You called the banned function '{}'.".format(name))
+
+ missing_funcs = self.find_missing_required_function_calls()
+ for name in missing_funcs:
+ errors.append("You forgot to use the required function '{}'.".format(name))
+
+ missing_funcs = self.find_missing_required_function_definitions()
+ for name in missing_funcs:
+ errors.append("You forgot to define the required function '{}'.".format(name))
+
+ missing_constructs = self.find_missing_required_constructs()
+ for reqd in missing_constructs:
+ errors.append("Your program must include at least one " + reqd + " statement.")
+
+ bad_constructs = self.find_illegal_constructs()
+ for notallowed in bad_constructs:
+ errors.append("Your program must not include any " + notallowed + "s.")
+
+ num_constants = len([line for line in self.student_answer.split('\n') if re.match(' *[A-Z_][A-Z_0-9]* *=', line)])
+ if num_constants > self.params['maxnumconstants']:
+ errors.append("You may not use more than " + str(self.params['maxnumconstants']) + " constants.")
+
+ # (mct63) Check if anything restricted is being imported.
+ if 'restrictedmodules' in self.params:
+ restricted = self.params['restrictedmodules']
+ for import_name, names in self.find_all_imports().items():
+ if import_name in restricted:
+ if restricted[import_name].get('onlyallow', None) == []:
+ errors.append("Your program should not import anything from '{}'.".format(import_name))
+ else:
+ for name in names:
+ if (('onlyallow' in restricted[import_name] and name not in restricted[import_name]['onlyallow']) or
+ name in restricted[import_name].get('disallow', [])):
+ errors.append("Your program should not import '{}' from '{}'.".format(name, import_name))
+
+ return errors
+
+ def find_all_imports(self):
+ """Returns a dictionary mapping in which the keys are all modules
+ being imported and the values are a list of what things within
+ the module are being modules. An empty list indicates the entire
+ module is imported."""
+ found_imports = {}
+ class ImportFinder(ast.NodeVisitor):
+ def visit_Import(self, node):
+ for alias in node.names:
+ if alias.name not in found_imports:
+ found_imports[alias.name] = []
+ self.generic_visit(node)
+ def visit_ImportFrom(self, node):
+ if node.module not in found_imports:
+ found_imports[node.module] = []
+ for alias in node.names:
+ found_imports[node.module].append(alias.name)
+ self.generic_visit(node)
+
+ visitor = ImportFinder()
+ visitor.visit(self.tree)
+ return found_imports
+
+ def find_all_function_calls(self):
+ """Return a dictionary mapping in which the keys are all functions
+ called by the source code and values are a list of
+ (line_number, nesting_depth) tuples."""
+ class FuncFinder(ast.NodeVisitor):
+
+ def __init__(self, *args, **kwargs):
+ self.depth = 0
+ self.found_funcs = defaultdict(list)
+ super().__init__(*args, **kwargs)
+
+ def visit_FunctionDef(self, node):
+ """ Every time we enter a function, we get 'deeper' into the code.
+ We want to note how deep a function is when we find its call."""
+ self.depth += 1
+ self.generic_visit(node)
+ self.depth -= 1
+
+ def visit_Call(self, node):
+ """A function has been called, so check its name
+ against the given one."""
+ try:
+ if 'id' in dir(node.func):
+ name = node.func.id
+ else:
+ name = node.func.attr
+ # Line numbers are 1-indexed, so decrement by 1
+ self.found_funcs[name].append((node.lineno - 1, self.depth))
+ except AttributeError:
+ pass # either not calling a function (??) or it's not named.
+ self.generic_visit(node)
+
+ if self.function_call_map is None:
+ visitor = FuncFinder()
+ visitor.visit(self.tree)
+ self.function_call_map = visitor.found_funcs
+ return self.function_call_map
+
+ def find_defined_functions(self):
+ """Find all the functions defined."""
+ defined = set()
+ class FuncFinder(ast.NodeVisitor):
+
+ def __init__(self):
+ self.prefix = ''
+
+ def visit_ClassDef(self, node):
+ old_prefix = self.prefix
+ self.prefix += node.name + '.'
+ self.generic_visit(node)
+ self.prefix = old_prefix
+
+ def visit_FunctionDef(self, node):
+ defined.add(self.prefix + node.name)
+ old_prefix = self.prefix
+ self.prefix += node.name + '.'
+ self.generic_visit(node)
+ self.prefix = old_prefix
+
+ def visit_AsyncFunctionDef(self, node):
+ self.visit_FunctionDef(node)
+
+ visitor = FuncFinder()
+ visitor.visit(self.tree)
+ return defined
+
+ def constructs_used(self):
+ """Return a set of all constructs encountered in the parse tree"""
+ constructs_seen = set()
+ class ConstructFinder(ast.NodeVisitor):
+ def visit_Assert(self, node):
+ constructs_seen.add('assert')
+ self.generic_visit(node)
+ def visit_Raise(self, node):
+ constructs_seen.add('raise')
+ self.generic_visit(node)
+ def visit_Lambda(self, node):
+ constructs_seen.add('lambda')
+ self.generic_visit(node)
+ def visit_Import(self, node):
+ constructs_seen.add('import')
+ self.generic_visit(node)
+ def visit_ImportFrom(self, node):
+ constructs_seen.add('import')
+ self.generic_visit(node)
+ def visit_For(self, node):
+ constructs_seen.add('for')
+ self.generic_visit(node)
+ def visit_While(self, node):
+ constructs_seen.add('while')
+ self.generic_visit(node)
+ def visit_Comprehension(self, node):
+ constructs_seen.add('comprehension')
+ self.generic_visit(node)
+ def visit_ListComp(self, node):
+ constructs_seen.add('listcomprehension')
+ self.generic_visit(node)
+ def visit_SetComp(self, node):
+ constructs_seen.add('setcomprehension')
+ self.generic_visit(node)
+ def visit_DictComp(self, node):
+ constructs_seen.add('dictcomprehension')
+ self.generic_visit(node)
+ def visit_Slice(self, node):
+ constructs_seen.add('slice')
+ def visit_If(self, node):
+ constructs_seen.add('if')
+ self.generic_visit(node)
+ def visit_Break(self, node):
+ constructs_seen.add('break')
+ self.generic_visit(node)
+ def visit_Continue(self, node):
+ constructs_seen.add('continue')
+ self.generic_visit(node)
+ def visit_Try(self, node):
+ constructs_seen.add('try')
+ self.generic_visit(node)
+ def visit_TryExcept(self, node):
+ constructs_seen.add('try')
+ constructs_seen.add('except')
+ self.generic_visit(node)
+ def visit_TryFinally(self, node):
+ constructs_seen.add('try')
+ constructs_seen.add('finally')
+ self.generic_visit(node)
+ def visit_ExceptHandler(self, node):
+ constructs_seen.add('except')
+ self.generic_visit(node)
+ def visit_With(self, node):
+ constructs_seen.add('with')
+ self.generic_visit(node)
+ def visit_Yield(self, node):
+ constructs_seen.add('yield')
+ self.generic_visit(node)
+ def visit_YieldFrom(self, node):
+ constructs_seen.add('yield')
+ self.generic_visit(node)
+ def visit_Return(self, node):
+ constructs_seen.add('return')
+ self.generic_visit(node)
+
+ visitor = ConstructFinder()
+ visitor.visit(self.tree)
+ return constructs_seen
+
+ def check_type_hints(self):
+ """Return a list of the names of functions that don't have full type hinting."""
+ unhinted = []
+ class MyVisitor(ast.NodeVisitor):
+ def visit_FunctionDef(self, node):
+ if node.returns is None or any([arg.annotation is None for arg in node.args.args]):
+ unhinted.append(node.name)
+
+ visitor = MyVisitor()
+ tree = self.tree
+ visitor.visit(self.tree)
+ return unhinted
+
+ def find_function_calls(self, name):
+ """Look for occurances of a specific function call"""
+ return self.find_all_function_calls().get(name, [])
+
+ def find_illegal_functions(self):
+ """Find a set of all the functions that the student uses
+ that they are not allowed to use. """
+ func_calls = self.find_all_function_calls()
+ return func_calls.keys() & set(self.params['proscribedfunctions'])
+
+ def find_missing_required_function_calls(self):
+ """Find a set of the required functions that the student fails to use"""
+ func_calls = self.find_all_function_calls()
+ return set(self.params['requiredfunctioncalls']) - func_calls.keys()
+
+ def find_missing_required_function_definitions(self):
+ """Find a set of required functions that the student fails to define"""
+ func_defs = self.find_defined_functions()
+ return set(self.params['requiredfunctiondefinitions']) - func_defs
+
+ def find_illegal_constructs(self):
+ """Find all the constructs that were used but not allowed"""
+ constructs = self.constructs_used()
+ return constructs & set(self.params['proscribedconstructs'])
+
+ def find_missing_required_constructs(self):
+ """Find which of the required constructs were not used"""
+ constructs = self.constructs_used()
+ return set(self.params['requiredconstructs']) - constructs
+
+ def find_too_long_funcs(self, max_length):
+ """Return a list of the functions that exceed the given max_length
+ Each list element is a tuple of the function name and the number of statements
+ in its body."""
+
+ bad_funcs = []
+
+ class MyVisitor(ast.NodeVisitor):
+
+ def visit_FunctionDef(self, node):
+
+ def count_statements(node):
+ """Number of statements in the given node and its children"""
+ count = 1
+ if isinstance(node, ast.Expr) and isinstance(node.value, ast.Str):
+ count = 0
+ else:
+ for attr in ['body', 'orelse', 'finalbody']:
+ if hasattr(node, attr):
+ children = node.__dict__[attr]
+ count += sum(count_statements(child) for child in children)
+ return count
+
+ num_statements = count_statements(node) - 1 # Disregard def itself
+ if num_statements > max_length:
+ bad_funcs.append((node.name, num_statements))
+
+ def visit_AsyncFunctionDef(self, node):
+ self.visit_FunctionDef(node)
+
+ visitor = MyVisitor()
+ visitor.visit(self.tree)
+ return bad_funcs
+
+ def find_global_code(self):
+ """Return a list of error messages relating to the existence of
+ any global assignment, for, while and if nodes. Ignores
+ global assignment statements with an ALL_CAPS target."""
+
+ global_errors = []
+ class MyVisitor(ast.NodeVisitor):
+ def visit_Assign(self, node):
+ if node.col_offset == 0:
+ if len(node.targets) > 1:
+ global_errors.append(f"Multiple targets in global assignment statement at line {node.lineno}")
+ elif not node.targets[0].id.isupper():
+ global_errors.append(f"Global assignment statement at line {node.lineno}")
+
+ def visit_For(self, node):
+ if node.col_offset == 0:
+ global_errors.append(f"Global for loop at line {node.lineno}")
+
+ def visit_While(self, node):
+ if node.col_offset == 0:
+ global_errors.append(f"Global while loop at line {node.lineno}")
+
+ def visit_If(self, node):
+ if node.col_offset == 0:
+ global_errors.append(f"Global if statement at line {node.lineno}")
+
+ visitor = MyVisitor()
+ visitor.visit(self.tree)
+ return global_errors
+
+ def find_nested_functions(self):
+ """Return a list of functions that are declared with non-global scope"""
+ bad_funcs = []
+
+ class MyVisitor(ast.NodeVisitor):
+ is_visiting_func = False
+
+ def visit_FunctionDef(self, node):
+ if self.is_visiting_func:
+ bad_funcs.append(node.name)
+ self.is_visiting_func = True
+ self.generic_visit(node) # Visit all children recursively
+ self.is_visiting_func = False
+
+ def visit_AsyncFunctionDef(self, node):
+ self.visit_FunctionDef(node)
+
+ visitor = MyVisitor()
+ visitor.visit(self.tree)
+ return bad_funcs
diff --git a/python3_stage1/__pystylechecker.prog4fun.py b/python3_stage1/__pystylechecker.prog4fun.py
new file mode 100644
index 0000000..acfe76f
--- /dev/null
+++ b/python3_stage1/__pystylechecker.prog4fun.py
@@ -0,0 +1,491 @@
+"""All the style checking code for Python3"""
+
+from io import BytesIO
+import os
+import sys
+import subprocess
+import ast
+import tokenize
+import token
+import re
+import shutil
+from collections import defaultdict
+
+class StyleChecker:
+ def __init__(self, prelude, student_answer, params):
+ self.prelude = prelude
+ self.student_answer = student_answer
+ self.params = params
+ self.function_call_map = None
+ self._tree = None
+
+ @property
+ def tree(self):
+ if self._tree is None:
+ self._tree = ast.parse(self.student_answer)
+ return self._tree
+
+ def style_errors(self):
+ """Return a list of errors from local style checks plus pylint and/or mypy
+ """
+ errors = []
+ source = open('__source.py', 'w', encoding="utf-8")
+ code_to_check = self.prelude + self.student_answer
+ prelude_len = len(self.prelude.splitlines())
+ source.write(code_to_check)
+ source.close()
+ env = os.environ.copy()
+ env['HOME'] = os.getcwd()
+ pylint_opts = self.params.get('pylintoptions',[])
+ precheckers = self.params.get('precheckers', ['pylint'])
+ result = ''
+
+ if 'pylint' in precheckers:
+ try: # Run pylint
+ cmd = f'{sys.executable} -m pylint ' + ' '.join(pylint_opts) + ' __source.py'
+ result = subprocess.check_output(cmd,
+ stderr=subprocess.STDOUT,
+ universal_newlines=True,
+ env=env,
+ shell=True)
+ except Exception as e:
+ result = e.output
+
+ else:
+ # (mct63) Abort if there are any comments containing 'pylint:'.
+ try:
+ tokenizer = tokenize.tokenize(BytesIO(self.student_answer.encode('utf-8')).readline)
+ for token_type, token_text, *_ in tokenizer:
+ if token_type == tokenize.COMMENT and 'pylint:' in token_text:
+ errors.append("Comments can not include 'pylint:'")
+ break
+
+ except Exception:
+ errors.append("Something went wrong while parsing comments. Report this.")
+
+ if "Using config file" in result:
+ result = '\n'.join(result.splitlines()[1:]).split()
+
+ if result == '' and 'mypy' in precheckers:
+ code_to_check = 'from typing import List as list, Dict as dict, Tuple as tuple, Set as set, Any\n' + code_to_check
+ with open('__source2.py', 'w', encoding='utf-8') as outfile:
+ outfile.write(code_to_check)
+ cmd = f'{sys.executable} -m mypy --no-error-summary --no-strict-optional __source2.py'
+ try: # Run mypy
+ subprocess.check_output(cmd, # Raises an exception if there are errors
+ stderr=subprocess.STDOUT,
+ universal_newlines=True,
+ env=env,
+ shell=True)
+ except Exception as e:
+ result = e.output
+ line_num_fix = lambda match: "Line " + str(int(match[1]) - 1 - prelude_len) + match[2]
+ result = re.sub(r'__source2.py:(\d+)(.*)', line_num_fix, result)
+
+ if result == '' and self.params.get('requiretypehints', False):
+ bad_funcs = self.check_type_hints()
+ for fun in bad_funcs:
+ result += f"Function '{fun}' does not have correct type hints\n"
+
+ if result:
+ errors = result.strip().splitlines()
+
+ return errors
+
+ def prettied(self, construct):
+ """Expand, if possible, the name of the given Python construct to a more
+ user friendly version, e.g. 'listcomprehension' -> 'list comprehension'
+ """
+ expanded = {
+ 'listcomprehension': 'list comprehension',
+ 'while': 'while loop',
+ 'for': 'for loop',
+ 'try': 'try ... except statement',
+ 'dictcomprehension': 'dictionary comprehension',
+ 'slice': 'slice'
+ }
+ if construct in expanded:
+ return expanded[construct]
+ else:
+ return f"{construct} statement"
+
+ def local_errors(self):
+ """Perform various local checks as specified by the current set of
+ template parameters.
+ """
+ errors = []
+
+ for banned in self.params.get('proscribedsubstrings', []):
+ if banned in self.student_answer:
+ errors.append(f"The string '{banned}' is not permitted anywhere in your code.")
+
+ for required in self.params.get('requiredsubstrings', []):
+ if isinstance(required, str) and required not in self.student_answer:
+ errors.append(f'The string "{required}" must occur somewhere in your code.')
+ elif isinstance(required, dict):
+ if 'pattern' in required and not re.findall(required['pattern'], self.student_answer):
+ errors.append(required['errormessage'])
+ elif 'string' in required and required['string'] not in self.student_answer:
+ errors.append(required['errormessage'])
+
+ if self.params.get('banglobalcode', True):
+ errors += self.find_global_code()
+
+ if not self.params.get('allownestedfunctions', True):
+ # Except for legacy questions or where explicitly allowed, nested functions are banned
+ nested_funcs = self.find_nested_functions()
+ for func in nested_funcs:
+ errors.append("Function '{}' is defined inside another function".format(func))
+
+ max_length = self.params['maxfunctionlength']
+ bad_funcs = self.find_too_long_funcs(max_length)
+ for func, count in bad_funcs:
+ errors.append("Function '{}' is too long\n({} statements, max is {})"
+ "".format(func, count, max_length))
+
+ bad_used = self.find_illegal_functions()
+ for name in bad_used:
+ errors.append("You called the banned function '{}'.".format(name))
+
+ missing_funcs = self.find_missing_required_function_calls()
+ for name in missing_funcs:
+ errors.append("You forgot to use the required function '{}'.".format(name))
+
+ missing_funcs = self.find_missing_required_function_definitions()
+ for name in missing_funcs:
+ errors.append("You forgot to define the required function '{}'.".format(name))
+
+ missing_constructs = self.find_missing_required_constructs()
+ for reqd in missing_constructs:
+ expanded = self.prettied(reqd)
+ errors.append(f"Your program must include at least one {expanded}.")
+
+ bad_constructs = self.find_illegal_constructs()
+ for notallowed in bad_constructs:
+ expanded = self.prettied(notallowed)
+ errors.append(f"Your program must not include any {expanded}s.")
+
+ num_constants = len([line for line in self.student_answer.split('\n') if re.match(' *[A-Z_][A-Z_0-9]* *=', line)])
+ if num_constants > self.params['maxnumconstants']:
+ errors.append("You may not use more than " + str(self.params['maxnumconstants']) + " constants.")
+
+ # (mct63) Check if anything restricted is being imported.
+ if 'restrictedmodules' in self.params:
+ restricted = self.params['restrictedmodules']
+ for import_name, names in self.find_all_imports().items():
+ if import_name in restricted:
+ if restricted[import_name].get('onlyallow', None) == []:
+ errors.append("Your program should not import anything from '{}'.".format(import_name))
+ else:
+ for name in names:
+ if (('onlyallow' in restricted[import_name] and name not in restricted[import_name]['onlyallow']) or
+ name in restricted[import_name].get('disallow', [])):
+ errors.append("Your program should not import '{}' from '{}'.".format(name, import_name))
+
+ return errors
+
+ def find_all_imports(self):
+ """Returns a dictionary mapping in which the keys are all modules
+ being imported and the values are a list of what things within
+ the module are being modules. An empty list indicates the entire
+ module is imported."""
+ found_imports = {}
+ class ImportFinder(ast.NodeVisitor):
+ def visit_Import(self, node):
+ for alias in node.names:
+ if alias.name not in found_imports:
+ found_imports[alias.name] = []
+ self.generic_visit(node)
+ def visit_ImportFrom(self, node):
+ if node.module not in found_imports:
+ found_imports[node.module] = []
+ for alias in node.names:
+ found_imports[node.module].append(alias.name)
+ self.generic_visit(node)
+
+ visitor = ImportFinder()
+ visitor.visit(self.tree)
+ return found_imports
+
+ def find_all_function_calls(self):
+ """Return a dictionary mapping in which the keys are all functions
+ called by the source code and values are a list of
+ (line_number, nesting_depth) tuples."""
+ class FuncFinder(ast.NodeVisitor):
+
+ def __init__(self, *args, **kwargs):
+ self.depth = 0
+ self.found_funcs = defaultdict(list)
+ super().__init__(*args, **kwargs)
+
+ def visit_FunctionDef(self, node):
+ """ Every time we enter a function, we get 'deeper' into the code.
+ We want to note how deep a function is when we find its call."""
+ self.depth += 1
+ self.generic_visit(node)
+ self.depth -= 1
+
+ def visit_Call(self, node):
+ """A function has been called, so check its name
+ against the given one."""
+ try:
+ if 'id' in dir(node.func):
+ name = node.func.id
+ else:
+ name = node.func.attr
+ # Line numbers are 1-indexed, so decrement by 1
+ self.found_funcs[name].append((node.lineno - 1, self.depth))
+ except AttributeError:
+ pass # either not calling a function (??) or it's not named.
+ self.generic_visit(node)
+
+ if self.function_call_map is None:
+ visitor = FuncFinder()
+ visitor.visit(self.tree)
+ self.function_call_map = visitor.found_funcs
+ return self.function_call_map
+
+
+ def find_defined_functions(self):
+ """Find all the functions defined."""
+ defined = set()
+ class FuncFinder(ast.NodeVisitor):
+
+ def __init__(self):
+ self.prefix = ''
+
+ def visit_ClassDef(self, node):
+ old_prefix = self.prefix
+ self.prefix += node.name + '.'
+ self.generic_visit(node)
+ self.prefix = old_prefix
+
+ def visit_FunctionDef(self, node):
+ defined.add(self.prefix + node.name)
+ old_prefix = self.prefix
+ self.prefix += node.name + '.'
+ self.generic_visit(node)
+ self.prefix = old_prefix
+
+ def visit_AsyncFunctionDef(self, node):
+ self.visit_FunctionDef(node)
+
+ visitor = FuncFinder()
+ visitor.visit(self.tree)
+ return defined
+
+
+ def constructs_used(self):
+ """Return a set of all constructs encountered in the parse tree"""
+ constructs_seen = set()
+ class ConstructFinder(ast.NodeVisitor):
+ def visit_Assert(self, node):
+ constructs_seen.add('assert')
+ self.generic_visit(node)
+ def visit_Raise(self, node):
+ constructs_seen.add('raise')
+ self.generic_visit(node)
+ def visit_Lambda(self, node):
+ constructs_seen.add('lambda')
+ self.generic_visit(node)
+ def visit_Import(self, node):
+ constructs_seen.add('import')
+ self.generic_visit(node)
+ def visit_ImportFrom(self, node):
+ constructs_seen.add('import')
+ self.generic_visit(node)
+ def visit_For(self, node):
+ constructs_seen.add('for')
+ self.generic_visit(node)
+ def visit_While(self, node):
+ constructs_seen.add('while')
+ self.generic_visit(node)
+ def visit_Comprehension(self, node):
+ constructs_seen.add('comprehension')
+ self.generic_visit(node)
+ def visit_ListComp(self, node):
+ constructs_seen.add('listcomprehension')
+ self.generic_visit(node)
+ def visit_SetComp(self, node):
+ constructs_seen.add('setcomprehension')
+ self.generic_visit(node)
+ def visit_DictComp(self, node):
+ constructs_seen.add('dictcomprehension')
+ self.generic_visit(node)
+ def visit_Slice(self, node):
+ constructs_seen.add('slice')
+ def visit_If(self, node):
+ constructs_seen.add('if')
+ self.generic_visit(node)
+ def visit_Break(self, node):
+ constructs_seen.add('break')
+ self.generic_visit(node)
+ def visit_Continue(self, node):
+ constructs_seen.add('continue')
+ self.generic_visit(node)
+ def visit_Try(self, node):
+ constructs_seen.add('try')
+ self.generic_visit(node)
+ def visit_TryExcept(self, node):
+ constructs_seen.add('try')
+ constructs_seen.add('except')
+ self.generic_visit(node)
+ def visit_TryFinally(self, node):
+ constructs_seen.add('try')
+ constructs_seen.add('finally')
+ self.generic_visit(node)
+ def visit_ExceptHandler(self, node):
+ constructs_seen.add('except')
+ self.generic_visit(node)
+ def visit_With(self, node):
+ constructs_seen.add('with')
+ self.generic_visit(node)
+ def visit_Yield(self, node):
+ constructs_seen.add('yield')
+ self.generic_visit(node)
+ def visit_YieldFrom(self, node):
+ constructs_seen.add('yield')
+ self.generic_visit(node)
+ def visit_Return(self, node):
+ constructs_seen.add('return')
+ self.generic_visit(node)
+
+ visitor = ConstructFinder()
+ visitor.visit(self.tree)
+ return constructs_seen
+
+ def check_type_hints(self):
+ """Return a list of the names of functions that don't have full type hinting."""
+ unhinted = []
+ class MyVisitor(ast.NodeVisitor):
+ def visit_FunctionDef(self, node):
+ if node.returns is None or any([arg.annotation is None for arg in node.args.args]):
+ unhinted.append(node.name)
+
+ visitor = MyVisitor()
+ tree = self.tree
+ visitor.visit(self.tree)
+ return unhinted
+
+ def find_function_calls(self, name):
+ """Look for occurances of a specific function call"""
+ return self.find_all_function_calls().get(name, [])
+
+
+ def find_illegal_functions(self):
+ """Find a set of all the functions that the student uses
+ that they are not allowed to use. """
+ func_calls = self.find_all_function_calls()
+ return func_calls.keys() & set(self.params['proscribedfunctions'])
+
+
+ def find_missing_required_function_calls(self):
+ """Find a set of the required functions that the student fails to use"""
+ func_calls = self.find_all_function_calls()
+ return set(self.params['requiredfunctioncalls']) - func_calls.keys()
+
+
+ def find_missing_required_function_definitions(self):
+ """Find a set of required functions that the student fails to define"""
+ func_defs = self.find_defined_functions()
+ return set(self.params['requiredfunctiondefinitions']) - func_defs
+
+
+ def find_illegal_constructs(self):
+ """Find all the constructs that were used but not allowed"""
+ constructs = self.constructs_used()
+ return constructs & set(self.params['proscribedconstructs'])
+
+
+ def find_missing_required_constructs(self):
+ """Find which of the required constructs were not used"""
+ constructs = self.constructs_used()
+ return set(self.params['requiredconstructs']) - constructs
+
+
+ def find_too_long_funcs(self, max_length):
+ """Return a list of the functions that exceed the given max_length
+ Each list element is a tuple of the function name and the number of statements
+ in its body."""
+
+ bad_funcs = []
+
+ class MyVisitor(ast.NodeVisitor):
+
+ def visit_FunctionDef(self, node):
+
+ def count_statements(node):
+ """Number of statements in the given node and its children"""
+ count = 1
+ if isinstance(node, ast.Expr) and isinstance(node.value, ast.Str):
+ count = 0
+ else:
+ for attr in ['body', 'orelse', 'finalbody']:
+ if hasattr(node, attr):
+ children = node.__dict__[attr]
+ count += sum(count_statements(child) for child in children)
+ return count
+
+ num_statements = count_statements(node) - 1 # Disregard def itself
+ if num_statements > max_length:
+ bad_funcs.append((node.name, num_statements))
+
+ def visit_AsyncFunctionDef(self, node):
+ self.visit_FunctionDef(node)
+
+ visitor = MyVisitor()
+ visitor.visit(self.tree)
+ return bad_funcs
+
+
+ def find_global_code(self):
+ """Return a list of error messages relating to the existence of
+ any global assignment, for, while and if nodes. Ignores
+ global assignment statements with an ALL_CAPS target."""
+
+ global_errors = []
+ class MyVisitor(ast.NodeVisitor):
+ def visit_Assign(self, node):
+ if node.col_offset == 0:
+ if len(node.targets) > 1 or isinstance(node.targets[0], ast.Tuple):
+ global_errors.append(f"Multiple targets in global assignment statement at line {node.lineno}")
+ elif not node.targets[0].id.isupper():
+ global_errors.append(f"Global assignment statement at line {node.lineno}")
+
+ def visit_For(self, node):
+ if node.col_offset == 0:
+ global_errors.append(f"Global for loop at line {node.lineno}")
+
+ def visit_While(self, node):
+ if node.col_offset == 0:
+ global_errors.append(f"Global while loop at line {node.lineno}")
+
+ def visit_If(self, node):
+ if node.col_offset == 0:
+ global_errors.append(f"Global if statement at line {node.lineno}")
+
+ visitor = MyVisitor()
+ visitor.visit(self.tree)
+ return global_errors
+
+
+ def find_nested_functions(self):
+ """Return a list of functions that are declared with non-global scope"""
+ bad_funcs = []
+
+ class MyVisitor(ast.NodeVisitor):
+ is_visiting_func = False
+
+ def visit_FunctionDef(self, node):
+ if self.is_visiting_func:
+ bad_funcs.append(node.name)
+ self.is_visiting_func = True
+ self.generic_visit(node) # Visit all children recursively
+ self.is_visiting_func = False
+
+ def visit_AsyncFunctionDef(self, node):
+ self.visit_FunctionDef(node)
+
+ visitor = MyVisitor()
+ visitor.visit(self.tree)
+ return bad_funcs
diff --git a/python3_stage1_gapfiller/__tester.py b/python3_stage1_gapfiller/__tester.py
index 82e98f1..78a1dd2 100644
--- a/python3_stage1_gapfiller/__tester.py
+++ b/python3_stage1_gapfiller/__tester.py
@@ -252,7 +252,7 @@ def prerun_hook(self):
return []
def get_all_images_html(self):
- """Search the current directory for images named _image.*(Expected|Got)(\d+).png.
+ r"""Search the current directory for images named _image.*(Expected|Got)(\d+).png.
For each such file construct an html img element with the data encoded
in a dataurl.
If we're running the sample answer, always return [] - images will be
diff --git a/python3_stage1_gapfiller/pytester.py b/python3_stage1_gapfiller/pytester.py
index 7b169d6..1819302 100644
--- a/python3_stage1_gapfiller/pytester.py
+++ b/python3_stage1_gapfiller/pytester.py
@@ -79,28 +79,36 @@ def has_docstring(self):
return prog.startswith('"') or prog.startswith("'")
def style_errors(self):
- """Return a list of all the style errors."""
- try:
- # Style-check the program without any test cases or other postlude added
- errors = self.style_checker.style_errors()
- except Exception as e:
- error_text = '*** Unexpected error while runner precheckers. Please report ***\n' + str(e)
- errors = [error_text]
- errors = [self.adjust_error_line_nums(error) for error in errors]
-
- if len(errors) == 0:
+ """Return a list of all the style errors. Start with local tests and continue with pylint
+ only if there are no local errors.
+ """
+ errors = []
+ if self.params.get('localprechecks', True):
try:
- errors = self.style_checker.local_errors() # Note: prelude not included so don't adjust line nums
+ errors += self.style_checker.local_errors() # Note: prelude not included so don't adjust line nums
except Exception as e:
- error_text = '*** Unexpected error while doing local style checks. Please report ***\n' + str(e)
- errors = [error_text]
-
- check_for_passive = (self.params['warnifpassiveoutput'] and self.params['isfunction'])
- if len(errors) == 0 and check_for_passive and self.passive_output():
- errors.append("Your code was not expected to generate any output " +
- "when executed stand-alone.\nDid you accidentally include " +
- "your test code?")
-
+ errors += [str(e)]
+ else:
+ check_for_passive = (self.params['warnifpassiveoutput'] and self.params['isfunction'])
+ if check_for_passive and self.passive_output():
+ errors.append("Your code was not expected to generate any output " +
+ "when executed stand-alone.\nDid you accidentally include " +
+ "your test code?")
+
+ if len(errors) == 0 or self.params.get('forcepylint', False):
+ # Run precheckers (pylint, mypy)
+ try:
+ # Style-check the program without any test cases or other postlude added
+ errors += self.style_checker.style_errors()
+ except Exception as e:
+ error_text = '*** Unexpected error while running precheckers. Please report ***\n' + str(e)
+ errors += [error_text]
+ errors = [self.simplify_error(self.adjust_error_line_nums(error)) for error in errors]
+ errors = [error for error in errors if not error.startswith('************* Module')]
+
+ errors = [error.replace(' : Xnnnn
+ """
+ pattern = r'__source.py:(\d+): *\d+: *[A-Z]\d+: (.*)'
+ match = re.match(pattern, error)
+ if match:
+ return f"Line {match.group(1)}: {match.group(2)}"
+ else:
+ return error
+
def main_hacks(self):
"""Modify the code to be tested if params stripmain or stripmainifpresent'
are specified. Returns a list of errors encountered while so doing.
@@ -219,7 +242,7 @@ def main_hacks(self):
for (line, depth) in main_calls:
if depth == 0:
main_call = student_lines[line]
- if not re.match(' *main\(\)', main_call):
+ if not re.match(r' *main\(\)', main_call):
errors.append(f"Illegal call to main().\n" +
"main should not take any parameters and should not return anything.")
else:
diff --git a/python3_state_sequence_table/PROTOTYPE_python3_state_sequence_table_tester.xml b/python3_state_sequence_table/PROTOTYPE_python3_state_sequence_table_tester.xml
new file mode 100644
index 0000000..6e3d406
--- /dev/null
+++ b/python3_state_sequence_table/PROTOTYPE_python3_state_sequence_table_tester.xml
@@ -0,0 +1,631 @@
+
+ This is a skeleton rewrite of the SST question type in which the SST parameters that were previously set by template parameters are now set by a control panel within the question. Only the num_vars and num_rows parameters are implemented so far. All the others (input_width, output_width, return_width) are still to be done. And the control panel should be hidden from the student, visible only to the teacher. To create a new question of this type:
+
+]]>
+
+ SST parameters
+ num_vars:
+
+ num_rows:
+
+ input_width:
+
+ output_width:
+
+ return_width:
+
Variable | Value |
---|---|
num_vars | |
num_rows | |
var_widths | |
output_width | |
return_width |
Test | Result | Valid test? | ' + for i, row in enumerate(outcome['testresults'][1:]): + is_correct = row[IS_CORRECT_COL] + html += '
---|---|---|
{row[result_table_col]} | '
+ if is_correct:
+ html += f'✔ | \n' + else: + html += f'✘ | \n' + + html += '
Your answer is an exact match with the author's solution.
" + else: + with open("__author_solution.html") as file: + PARAMS['AUTHOR_ANSWER'] = (file.read().strip() % html.escape(answer)) + else: + PARAMS['AUTHOR_ANSWER'] = PARAMS['AUTHOR_ANSWER_SCRAMBLED'] = '' + + +def update_test_cases(test_cases, outcome): + """Return the updated testcases after replacing all empty expected fields with those from the + given outcome's test_results which must have a column header 'Got'. Non-empty existing expected + fields are left unchanged. + If any errors occur, the return value will be None and the outcome parameter will have had its prologuehtml + value updated to include an error message. + """ + try: + results = outcome['testresults'] + col_num = results[0].index('Got') + for i in range(len(test_cases)): + if test_cases[i].expected.strip() == '': + test_cases[i].expected = results[i + 1][col_num] + except ValueError: + outcome['prologuehtml'] = "No 'Got' column in result table from which to get testcase expecteds" + test_cases = None + except Exception as e: + outcome['prologuehtml'] = "Unexpected error ({}) extracting testcase expecteds from sample answer output".format(e) + test_cases = None + return test_cases + + +def run_tests(params, test_cases): + """Run all tests using the sample answer rather than the student answer""" + new_params = {key: value for key, value in params.items()} + new_params['IS_PRECHECK'] = False + new_params['nostylechecks'] = True + new_params['STUDENT_ANSWER'] = get_answer() + new_params['running_sample_answer'] = True + tester = PyTester(new_params, test_cases) + outcome = tester.test_code() + return outcome + + +def get_expecteds_from_answer(params, test_cases): + """Run all tests using the sample answer rather than the student answer. + Fill in the expected field of each test case using the sample answer and return + the updated test case list. + Return None if the sample answer gave any sort of runtime error + """ + outcome = run_tests(params, test_cases) + if 'prologuehtml' in outcome: + outcome['prologuehtml'] = "Sorry but the following {test_is} not in the required form. +Please re-read the question to see what was expected. Ask a tutor if you're still unsure. +
{code}