From d6b0e78c433f40b52f6167b1297f9320ae8d7847 Mon Sep 17 00:00:00 2001
From: Tue Herlau <tuhe@dtu.dk>
Date: Tue, 24 Oct 2023 11:39:23 +0200
Subject: [PATCH] Worked on timeout function

---
 src/unitgrade_devel.egg-info/PKG-INFO         |  12 +-
 src/unitgrade_private/deployment.py           |  10 +-
 src/unitgrade_private/hidden_create_files.py  |   5 +-
 src/unitgrade_private/hidden_gather_upload.py |  25 +-
 src/unitgrade_private/pipelines/dtulearn.py   | 213 +++++++++++++++---
 src/unitgrade_private/token_loader.py         |  25 +-
 src/unitgrade_private/utils.py                |  36 +++
 src/unitgrade_private/version.py              |   3 +-
 8 files changed, 271 insertions(+), 58 deletions(-)
 create mode 100644 src/unitgrade_private/utils.py

diff --git a/src/unitgrade_devel.egg-info/PKG-INFO b/src/unitgrade_devel.egg-info/PKG-INFO
index 6bce4bc..fddaac7 100644
--- a/src/unitgrade_devel.egg-info/PKG-INFO
+++ b/src/unitgrade_devel.egg-info/PKG-INFO
@@ -1,6 +1,6 @@
 Metadata-Version: 2.1
 Name: unitgrade-devel
-Version: 0.1.55
+Version: 0.1.60
 Summary: A set of tools to develop unitgrade tests and reports and later evaluate them
 Home-page: https://lab.compute.dtu.dk/tuhe/unitgrade_private
 Author: Tue Herlau
@@ -13,6 +13,16 @@ Classifier: Operating System :: OS Independent
 Requires-Python: >=3.8
 Description-Content-Type: text/markdown
 License-File: LICENSE
+Requires-Dist: unitgrade
+Requires-Dist: numpy
+Requires-Dist: codesnipper
+Requires-Dist: tabulate
+Requires-Dist: tqdm
+Requires-Dist: pyfiglet
+Requires-Dist: jinja2
+Requires-Dist: colorama
+Requires-Dist: coverage
+Requires-Dist: mosspy
 
 # Unitgrade-devel
 **Note: This is the development version of unitgrade. If you are a student, please see http://gitlab.compute.dtu.dk/tuhe/unitgrade.**
diff --git a/src/unitgrade_private/deployment.py b/src/unitgrade_private/deployment.py
index 01cc6c4..90d365e 100644
--- a/src/unitgrade_private/deployment.py
+++ b/src/unitgrade_private/deployment.py
@@ -2,7 +2,7 @@ import inspect
 from unitgrade.utils import hide, methodsWithDecorator
 import os
 import importlib
-
+import snipper
 
 def remove_hidden_methods(ReportClass, outfile=None):
     # Given a ReportClass, clean out all @hidden tests from the imports of that class.
@@ -14,7 +14,7 @@ def remove_hidden_methods(ReportClass, outfile=None):
 
     for Q,_ in ReportClass.questions:
         ls = list(methodsWithDecorator(Q, hide))
-        print("hide decorateed is", ls)
+        # print("hide decorateed is", ls)
         for f in ls:
             s, start = inspect.getsourcelines(f)
             end = len(s) + start
@@ -28,6 +28,12 @@ def remove_hidden_methods(ReportClass, outfile=None):
 
     if os.path.exists(outfile) and os.path.samefile(file, outfile):
         raise Exception("Similar file paths identified!")
+
+    # Allows us to use the !b;silent tags in the code. This is a bit hacky, but allows timeouts, etc. to make certain tests more robust
+    from snipper.fix_bf import fix_b
+    lines, _, _ = fix_b(source.splitlines())
+    source = "\n".join(lines)
+
     with open(os.path.dirname(file) + "/" + outfile, 'w') as f:
         f.write(source)
 
diff --git a/src/unitgrade_private/hidden_create_files.py b/src/unitgrade_private/hidden_create_files.py
index a29a606..9f0cc2f 100644
--- a/src/unitgrade_private/hidden_create_files.py
+++ b/src/unitgrade_private/hidden_create_files.py
@@ -52,7 +52,7 @@ def setup_grade_file_report(ReportClass, execute=False, obfuscate=False, minify=
                 raise Exception("Must spacify name to give new report file")
 
         fout, Report = remove_hidden_methods(ReportClass, outfile=name_without_hidden)  # Create report3.py without @hide-methods
-        return setup_grade_file_report(Report, remove_hidden=False) # Create report3_grade.py for the students
+        return setup_grade_file_report(Report, remove_hidden=False, bzip=bzip) # Create report3_grade.py for the students
 
     print("Setting up answers...")
     url = ReportClass.url
@@ -164,14 +164,11 @@ def setup_grade_file_report(ReportClass, execute=False, obfuscate=False, minify=
                             unitgrade.evaluate.__file__, hidden_gather_upload.__file__,
                             version.__file__], excl) + "\n" + report1_source
 
-    # print(sys.getsizeof(picklestring))
-    # print(len(picklestring))
     s = jinja2.Environment().from_string(data).render({'Report1': ReportClass.__name__,
                                                        'source': repr(report1_source),
                                                        'payload': picklestring.hex(), #repr(picklestring),
                                                        'token_out': repr(fn[:-3] + "_handin"),
                                                        'head': pyhead})
-
     # if fn[:-3].endswith("_test.py"):
     #     output = fn[:-8] + "_grade.py"
     # elif fn.endswith("_tests.py"):
diff --git a/src/unitgrade_private/hidden_gather_upload.py b/src/unitgrade_private/hidden_gather_upload.py
index 6ce9fa6..1ab502f 100644
--- a/src/unitgrade_private/hidden_gather_upload.py
+++ b/src/unitgrade_private/hidden_gather_upload.py
@@ -1,5 +1,5 @@
 import sys
-from unitgrade.evaluate import evaluate_report, python_code_str_id, _print_header
+from unitgrade.evaluate import evaluate_report, _print_header, python_code_binary_id
 import textwrap
 import bz2
 import pickle
@@ -36,13 +36,23 @@ def gather_imports(imp):
         for root, dirs, files in os.walk(top_package):
             for file in files:
                 if file.endswith(".py"):
+                    # print("file is", file)
                     fpath = os.path.join(root, file)
                     v = os.path.relpath(fpath, os.path.dirname(top_package) if not module_import else top_package)
                     zip.write(fpath, v)
                     if not fpath.endswith("_grade.py"): # Exclude grade files.
-                        with open(fpath, 'r') as f:
-                            s = f.read()
-                        found_hashes[v] = python_code_str_id(s)
+
+                        # print("I am reading the file", fpath)
+                        try:
+                            with open(fpath, 'rb') as f:
+                                s = f.read()
+                        except UnicodeDecodeError as e:
+                            print("Unitgrade> Oh no, I tried to read the file", f)
+                            print("But I got an error since the file contains unusual content (non-unicode characters). Please go over the file and check that it looks okay. Things that can cause this problem are unusual characters (Japanese, Arabic, etc.) or Emojis.")
+                            print("This problem sometimes also occurs when virus scanners or viruses write unusual files on your computer. In that case, try to disable or remove the program.")
+                            raise e
+                        # print("Read")
+                        found_hashes[v] = python_code_binary_id(s)
                         resources['pycode'][v] = s
 
     resources['zipfile'] = zip_buffer.getvalue()
@@ -105,11 +115,13 @@ def gather_upload_to_campusnet(report, output_dir=None, token_include_plaintext_
 
     if report.remote_url is not None and args.evaluation:
         import datetime
+        # print("Performing a remote evaluation...")
         _print_header(now=datetime.datetime.now(), big_header=True)
 
         # get the remote evaluation.
         # os.path.dirname(report._artifact_file())
         mf = report._manifest_file()
+        # print("looking for manifest file", mf)
         if os.path.isfile(mf):
             with open(mf, 'r') as f:
                 s = f.read()
@@ -118,7 +130,7 @@ def gather_upload_to_campusnet(report, output_dir=None, token_include_plaintext_
             results = checkout_remote_results(report.remote_url, s.strip())
             if results['html'] is None:
                 print("""Oy! I failed to download the verified results. 
-There can be three reasons why this this command failed:
+There are four likely reasons why this this command failed:
                 
     * You have not yet uploaded a .token file (i.e., you have not yet handed in)
     * You did not upload a .token file, but rather a file in some other format. 
@@ -131,8 +143,7 @@ for more information about what happened to your evaluation. Please don't contac
                 sys.exit()
             print("Your verified results are:")
             import tabulate
-            # import sys
-            print( tabulate.tabulate(results['df'], showindex=False) )
+            print( tabulate.tabulate(results['dict'], headers="keys" ) )
             print("These scores are based on our internal tests.")
             print("To see all your results, visit:")
             print(">", results['url'])
diff --git a/src/unitgrade_private/pipelines/dtulearn.py b/src/unitgrade_private/pipelines/dtulearn.py
index 20baeef..c447926 100644
--- a/src/unitgrade_private/pipelines/dtulearn.py
+++ b/src/unitgrade_private/pipelines/dtulearn.py
@@ -114,7 +114,19 @@ def write_summary_report_xlsx_file(write_html=True, open_browser=True):
     return dd
 
 
-
+def f2date(f):
+    date = os.path.basename(f).split("-")[-1].strip()
+
+    import datetime
+    # date = " 31 August, 2023 3:44 PM"
+    # date = "31 August, 2023"
+    # date = "3:44 PM"
+    # datetime_obj = datetime.datetime.strptime(date.strip(), "%I:%M %p")
+    if ":" in date:
+        datetime_obj = datetime.datetime.strptime(date.strip(), "%d %B, %Y  %I:%M %p")
+    else:
+        datetime_obj = datetime.datetime.strptime(date.strip(), "%d %B, %Y  %I%M %p")
+    return datetime_obj
 
 
 def docker_stagewise_evaluation(base_directory, Dockerfile=None, instructor_grade_script=None,
@@ -126,6 +138,7 @@ def docker_stagewise_evaluation(base_directory, Dockerfile=None, instructor_grad
                                 configuration=None,
                                 unmute_docker = True,
                                 plagiarism_check=False,
+                                accept_problems=False, # No!
                                 ):
     """
     This is the main verification scripts. It is the main entry point for project verifications as downloaded from DTU Learn.
@@ -189,40 +202,69 @@ def docker_stagewise_evaluation(base_directory, Dockerfile=None, instructor_grad
         # stage0_excluded_files = ["*.pdf"]
         stage0_excluded_files = configuration['stage0']['excluded_files']
         found = []
+
+        # ids_and_directories = []
+
+        relevant_directories = {}
+
         # Set up stage 1:
         for z in glob.glob(f"{stage0_dir}/*.*"):
             if not z.endswith(".zip"):
                 raise Exception("The downloaded files must be .zip files from DTU Learn")
 
             unpack_zip_file_recursively(z[:-4] + ".zip", z[:-4] + "/raw", remove_zipfiles=True)
+
             for f in glob.glob(z[:-4] + "/raw/*"):
                 if os.path.basename(f) == "index.html":
                     continue
                 elif os.path.isdir(f):
                     id = fname2id(os.path.basename(f), info)
-                    if id in found:
-                        assert False
-                    found.append(id)
-                    dest = stage1_dir +"/" + id
-                    if not os.path.isdir(dest):
-                        shutil.copytree(f, dest )
-                        # Now remove blacklisted files to simplify it.
-                        for g in glob.glob(dest +"/**/*", recursive=True):
-                            import fnmatch
-                            if g.endswith(".py"):
-                                print(g)
-                            if os.path.basename(g) in configuration['stage0']['rename']:
-                                dst_name = configuration['stage0']['rename'][os.path.basename(g)]
-                                dst_name = os.path.dirname(g) + "/" + dst_name
-                                assert not os.path.isfile(dst_name)
-                                shutil.move(g, dst_name)
-
-                            if len([ex for ex in stage0_excluded_files if fnmatch.fnmatch(g, ex)]) > 0:
-                                os.remove(g)
 
+                    # now get the directory.
+
+                    if id not in relevant_directories:
+                        relevant_directories[id] = f
+                    else:
+                        dold = f2date(relevant_directories[id])
+                        dnew = f2date(f)
+                        if dnew == dold:
+                            pass
+                            raise Exception("User has two handins with the same date. Not possible. \n" + f + "\n " + relevant_directories[id])
+
+                        if dnew > dold:
+                            relevant_directories[id] = f
                 else:
                     assert student_handout_folder is not None
-                    raise Exception("The .zip files can only contain directories with names such as: '67914-43587 - s214598, Andreas Rahbek-Palm - 09 February, 2023 441 PM'")
+                    raise Exception(
+                        "The .zip files can only contain directories with names such as: '67914-43587 - s214598, Andreas Rahbek-Palm - 09 February, 2023 441 PM', got " + student_handout_folder)
+
+            for id, f in relevant_directories.items():
+                found.append(id)
+                dest = stage1_dir +"/" + id
+
+                if not os.path.isdir(dest):
+                    shutil.copytree(f, dest )
+                else:
+                    # merge the files...
+                    for new_file in glob.glob(f +"/**/*", recursive=True):
+                        # print(os.path.relpath(new_file, f))
+                        shutil.copy(new_file, dest + "/"+os.path.relpath(new_file, f))
+
+                # Now remove blacklisted files to simplify it.
+                for g in glob.glob(dest +"/**/*", recursive=True):
+                    import fnmatch
+                    if g.endswith(".py"):
+                        print(g)
+                    if os.path.basename(g) in configuration['stage0']['rename']:
+                        dst_name = configuration['stage0']['rename'][os.path.basename(g)]
+                        dst_name = os.path.dirname(g) + "/" + dst_name
+                        if not os.path.isfile(dst_name):
+                            shutil.move(g, dst_name)
+
+                    if len([ex for ex in stage0_excluded_files if fnmatch.fnmatch(g, ex)]) > 0:
+                        os.remove(g)
+
+
     _stage0()
 
     def _stage1():
@@ -279,6 +321,7 @@ def docker_stagewise_evaluation(base_directory, Dockerfile=None, instructor_grad
         return os.path.dirname(gs) + "/"+os.path.basename(instructor_grade_script)
 
     def _stage2(fix_user=True, xvfb=True):
+        # configuration
         """ Unpack token or prep python files. """
         for fid in glob.glob(stage2_dir + "/*"):
             # print(fid)
@@ -289,26 +332,76 @@ def docker_stagewise_evaluation(base_directory, Dockerfile=None, instructor_grad
             grade_script_relative = get_grade_script_location(instructor_grade_script)
             if type == "token":
                 tokens = glob.glob(fid + "/**/*.token", recursive=True)
-                assert len(tokens) == 1
-                unpack_sources_from_token(tokens[0], s3dir)
+                assert len(tokens) == 1, f"{id} has too many tokens: The tokens found are {tokens}"
+                try:
+                    unpack_sources_from_token(tokens[0], s3dir)
+                except Exception as e:
+                    print("-" * 100)
+                    print("Not a valid token file", tokens[0], "investigate and potentially blacklist", id)
+
+                    if id in configuration.get('stage2', {}).get('skip_students', []):
+                        pass
+                    else:
+                        raise e
+
                 # This will copy in resource files etc. that may not be contained in the .token file.
                 for g in glob.glob(student_handout_folder + "/**/*.*", recursive=True):
                     rg = os.path.relpath(g, student_handout_folder)
                     if not os.path.isfile(s3dir + "/"+rg) and not rg.endswith(".py"):
                         if not os.path.isdir(os.path.dirname(s3dir + "/"+rg)): os.makedirs(os.path.dirname(s3dir + "/"+rg))
-                        shutil.copy(g, s3dir + "/"+rg)
+                        if os.path.isfile(g):
+                            shutil.copy(g, s3dir + "/"+rg)
+                        else:
+                            shutil.copytree(g, s3dir + "/" + g)
             else:
                 shutil.copytree(student_handout_folder, s3dir)
                 for g in glob.glob(fid+"/**/*.*", recursive=True):
+                    # Find location in student handout folder.
+                    fn = glob.glob(student_handout_folder + "/**/" + os.path.basename(g), recursive=True)
+                    if len(fn) == 0:
+                        print("I was unable to locate", g)
+                        print("Bad?")
+                    os.path.relpath(fn[0], student_handout_folder)
+
                     dst = s3dir + "/"+os.path.dirname(grade_script_relative) + "/"+ os.path.basename(g)
+                    dst = s3dir + "/" + os.path.relpath(fn[0], student_handout_folder)
+
                     if os.path.isfile(dst):
                         shutil.copy(g, dst)
                     else:
                         shutil.move(g, dst)
                         print("> Stage two: Created", dst)
+
             ### Copy in the instructor grade script. We are now ready for deployment.
             shutil.copy(instructor_grade_script, os.path.dirname(s3dir + "/" + grade_script_relative) + "/" + os.path.basename(instructor_grade_script))
 
+            ## Check files are readable...
+        for fid in glob.glob(stage2_dir + "/*"):
+            # print(fid)
+            id, type = os.path.basename(fid).split("-")
+            s3dir = f"{stage3_dir}/{os.path.basename(fid)}"
+            # if os.path.isdir(s3dir):
+            #     continue
+            for f in glob.glob(s3dir +"/**/*.py", recursive=True):
+                if os.path.isdir(f):
+                    continue
+                try:
+                    with open(f, 'r') as ff:
+                        ff.read()
+                except UnicodeDecodeError as e:
+                    print("""Student file not readable. add to stage2 kill list as in { configurations['projects']['project1']['stage3']['exclude_if_bad_encoding'] += ['*/~BROMIUM/*.py'] }""", f)
+                    for p in configuration['stage2'].get('exclude_if_bad_encoding', []):
+                        if fnmatch.fnmatch(f, p):
+                            print("Skipping file with shit encoding", f)
+                            os.remove(f)
+                            break
+                    if os.path.isfile(f):
+                        raise e
+
+
+
+
+
     _stage2()
 
     def _stage3(Dockerfile, fix_user=True, xvfb=True, unmute=False, verbose=False):
@@ -350,9 +443,35 @@ def docker_stagewise_evaluation(base_directory, Dockerfile=None, instructor_grad
 
                 if len(products) == 0: # No .token file has actually been generated. So obviously we have to re-generate it.
                     RERUN_TOKEN = True
-                elif len(student_token_file) > 0:
-                    stoken, _ = load_token(student_token_file[0])
-                    s_better_than_i, _ = determine_token_difference(stoken, rc)
+                elif len(student_token_file) > 0 and id not in configuration.get('stage2', {}).get('skip_students', []):
+                    # We check if the student id is marked as skipped. This is reserved for cases where student uploads a token file, but it is fundamentally broken (as determined by manual inspection).
+                    if len(student_token_file) == 0:
+                        print(f"Strange error in stage 3: this student did not have a token file {id}")
+                    try:
+                        stoken, _ = load_token(student_token_file[0])
+                    except Exception as e:
+                        print(f"did not load token file for student {id}: {student_token_file}")
+                        raise e
+                    # if os.path.basename(student_token_file[0]).split("_")[0] != os.path.basename(products[0]).split("_")[0]:
+                    #     print("bad")
+                    # We check if the student ran the actual token file they were supposed to run. If not, it may still have the right sources...
+                    if "sources" not in rc:
+                        print("no sources")
+
+                    ptoken = load_token(products[0])[0]
+                    if ".".join(stoken['sources'][0]['report_module_specification']).lower().replace(" ", "") == ".".join(ptoken['sources'][0]['report_module_specification']).replace("_tests_complete", "").lower(): #
+                        s_better_than_i, _ = determine_token_difference(stoken, rc)
+                        acceptable_broken = False
+                    elif id in configuration.get('stage3', {}).get('accept_incompatible_token_names', []):
+                        print("Incompatible token names accepted...")
+                        s_better_than_i = []
+                        acceptable_broken = True
+                    else:
+                        print(".".join(stoken['sources'][0]['report_module_specification']).lower())
+                        print(".".join(rc['sources'][0]['report_module_specification']).replace("_tests_complete", "").lower())
+
+                        raise Exception("Bad student token. Add id incompatible token names " + str(student_token_file) )
+                        pass
 
                     if len(s_better_than_i) > 0:
                         for q in s_better_than_i:
@@ -363,9 +482,20 @@ def docker_stagewise_evaluation(base_directory, Dockerfile=None, instructor_grad
                     rch = token_gather_hidden(rc)
 
                     for q in stoken['details']:
+                        if acceptable_broken:
+                            continue
                         for item in stoken['details'][q]['items']:
+                            if item ==  ('Week06SentimentAnalysis', 'test_sentiment_analysis'):
+                                continue
                             sitem = stoken['details'][q]['items'][item]
+                            if item == ("Week06SpellCheck", "test_SpellCheck"):
+                                item = ("Week06SpellCheck", "test_spell_check")
+
+                            if item not in rch['details'][q]['items']:
+
+                                print( rch['details'][q]['items'].keys() )
                             iitems = rch['details'][q]['items'][item]
+
                             if sitem['status'] == 'pass' and not all([i['status'] == 'pass' for i in iitems]) and id not in conf.get('verified_problematic_items', {}).get(item, []):
                                 # print('disagreement found.')
                                 iitems = rch['details'][q]['items'][item]
@@ -392,6 +522,8 @@ def docker_stagewise_evaluation(base_directory, Dockerfile=None, instructor_grad
 
                                 RERUN_TOKEN = True
                                 nn += 1
+            else:
+                print("No token rerunning", s4dir)
 
             if not RERUN_TOKEN:
                 # Check if the instructor script is identical to the current one.
@@ -442,7 +574,7 @@ def docker_stagewise_evaluation(base_directory, Dockerfile=None, instructor_grad
             if unmute: # This is a pretty big mess.
                 from unitgrade_private.run import run
                 out = run(fcom, print_output=True, log_output=False, check=False)
-                stdout = out.stderr.getvalue()
+                stdout = out.stdout.getvalue()
                 stderr = out.stderr.getvalue()
 
                 if not os.path.isdir(s4dir):
@@ -466,7 +598,14 @@ def docker_stagewise_evaluation(base_directory, Dockerfile=None, instructor_grad
 
             for f in glob.glob(s4dir + "/*.token"):
                 os.remove(f)
-            shutil.move(tokens[0], s4dir + "/" + os.path.basename(tokens[0]))
+            try:
+                shutil.move(tokens[0], s4dir + "/" + os.path.basename(tokens[0]))
+            except Exception as e:
+                print("-"*50)
+                print("Got a problem wit hthis student")
+                print("dir", s4dir)
+                print("tokens", tokens)
+                raise e
 
     _stage3(Dockerfile, unmute=unmute_docker)
 
@@ -481,8 +620,11 @@ def docker_stagewise_evaluation(base_directory, Dockerfile=None, instructor_grad
             for tf in glob.glob(fid +"/**/*.token", recursive=True):
                 rs[id]['token_downloaded'] = tf
 
-                tdata, _ = load_token(tf)
-                blake_hash = tdata['metadata']['file_reported_hash']
+                if id in configuration.get('stage2', {}).get('skip_students', []):
+                    blake_hash = "BAD TOKEN: STUDENT IS MARKED AS SKIPPED."
+                else:
+                    tdata, _ = load_token(tf)
+                    blake_hash = tdata['metadata']['file_reported_hash']
 
                 rs[id]['token_downloaded_hash'] = blake_hash
 
@@ -515,7 +657,7 @@ def docker_stagewise_evaluation(base_directory, Dockerfile=None, instructor_grad
             elif 'token' not in found_students[id] and 'python' in found_students[id]:
                 if id not in configuration.get('stage_report', {}).get("python_handin_checked", []):
                     print("="*50)
-                    s = f"{id}> only handed in the .py files and not the .token files. " +str(found_students[id]['python'])
+                    s = f"{id}> only handed in the .py files and not the .token files. " +str(found_students[id]['python'] + " to skip this mesage, alter the stage_report['python_handin_checked'] field. ")
                     messages['report'].append(s)
                     stoken =token_gather_hidden(load_token(found_students[id]['python'])[0])
                     print(s)
@@ -548,6 +690,8 @@ def docker_stagewise_evaluation(base_directory, Dockerfile=None, instructor_grad
             for s in messages[stage]:
                 print(">> ", s)
         print("-" * 50)
+        if accept_problems:
+            assert False, "No messages allowed!"
 
     if plagiarism_check and True:
         from unitgrade_private.plagiarism.mossit import moss_it2023
@@ -841,7 +985,10 @@ def fname2id(fname, info=None):
         id = id_cand
         if info is not None:
             if not id[1:].isdigit():
-                real_id = [sid for sid in info['students'] if info['students'][sid]['initials']  == id].pop()
+                possible = [sid for sid in info['students'] if info['students'][sid]['initials']  == id]
+                if len(possible) == 0:
+                    raise Exception(f"Tried to find student id {id} but was not found. You need to download the CSV file from inside and put it in the main config excel sheet.")
+                real_id = possible.pop()
                 if real_id != id:
                     print("Changing id from", id, "to", real_id)
                     id = real_id
diff --git a/src/unitgrade_private/token_loader.py b/src/unitgrade_private/token_loader.py
index b3e4eb8..7321ea2 100644
--- a/src/unitgrade_private/token_loader.py
+++ b/src/unitgrade_private/token_loader.py
@@ -36,13 +36,16 @@ def get_coverage_files(token_file, instructor_grade_script_dir):
 
     for q in stoken['details']:
         cov_files[q] = {}
+        try:
+            with open(pkl_file := f"{instructor_grade_script_dir}/unitgrade_data/{stoken['details'][q]['name']}.pkl", 'rb') as f:
+                pk = pickle.load(f)
+            for item in stoken['details'][q]['items']:
+                key = (item, 'coverage')
+                if key in pk:
+                    cov_files[q][key] = list( pk[(item, 'coverage')].keys() )
+        except Exception as e:
+            print("Unitgrade> Failed to load a coverage file. This may indicate that files have been removed from the unitgrade_data directory. Skipping and possibly returning a too small list.", pkl_file)
 
-        with open(f"{instructor_grade_script_dir}/unitgrade_data/{stoken['details'][q]['name']}.pkl", 'rb') as f:
-            pk = pickle.load(f)
-        for item in stoken['details'][q]['items']:
-            key = (item, 'coverage')
-            if key in pk:
-                cov_files[q][key] = list( pk[(item, 'coverage')].keys() )
     return cov_files
 
 
@@ -117,8 +120,12 @@ def determine_token_difference(student_token_rs, instructor_token_rs):
                         a_better_than_b[q] = {'items': {}}
                     a_better_than_b[q]['items'][item] = {'a': a[q]['items'][item], 'b': b[q]['items'].get(item,None)}
         return a_better_than_b
-    a_better_than_b = where_a_better_b(rsa['details'], rsb['details'])
-    b_better_than_a = where_a_better_b(rsb['details'], rsa['details'])
+    try:
+        a_better_than_b = where_a_better_b(rsa['details'], rsb['details'])
+        b_better_than_a = where_a_better_b(rsb['details'], rsa['details'])
+    except Exception as e:
+        print("Oh no", student_token_rs, instructor_token_rs)
+        raise e
     return a_better_than_b, b_better_than_a
 
 
@@ -176,7 +183,7 @@ def combine_token_results(token_a_rs, token_b_rs):
         n_obt += nc
 
 
-    rs = dict(total=(n_obt, n_tot), details=rsd, sources=None)
+    rs = dict(total=(n_obt, n_tot), details=rsd, sources=None, metadata=None)
     return rs
 
 
diff --git a/src/unitgrade_private/utils.py b/src/unitgrade_private/utils.py
new file mode 100644
index 0000000..ae4bd62
--- /dev/null
+++ b/src/unitgrade_private/utils.py
@@ -0,0 +1,36 @@
+"""
+Tue: This may become part of unitgrade proper at some point. It will allow automatic timeout of tests, but right now it is used
+to timeout hidden tests which ends up in an infinite loop.
+
+"""
+import sys
+import threading
+from time import sleep
+# try:
+#     import thread
+# except ImportError:
+import _thread as thread
+
+def quit_function(fn_name):
+    # print to stderr, unbuffered in Python 2.
+    # print('{0} took too long'.format(fn_name), file=sys.stderr)
+    # sys.stderr.flush() # Python 3 stderr is likely buffered.
+    thread.interrupt_main() # raises KeyboardInterrupt
+
+def exit_after(s):
+    '''
+    use as decorator to exit process if
+    function takes longer than s seconds
+    '''
+    def outer(fn):
+        def inner(*args, **kwargs):
+            timer = threading.Timer(s, quit_function, args=[fn.__name__])
+            timer.start()
+            try:
+                result = fn(*args, **kwargs)
+            finally:
+                timer.cancel()
+            return result
+        return inner
+    return outer
+
diff --git a/src/unitgrade_private/version.py b/src/unitgrade_private/version.py
index cc97862..aa5efd5 100644
--- a/src/unitgrade_private/version.py
+++ b/src/unitgrade_private/version.py
@@ -1,2 +1 @@
-__version__ = "0.1.56"
-
+__version__ = "0.1.60"
-- 
GitLab