import sys, os, re, shutil mylib = os.path.join(os.environ['HOME'], 'src', 'python') grlib = os.path.join(os.environ['HOME'], 'src', 'grading') sys.path.extend([mylib, grlib]) from Util import * from . import Record # It is common to use uppercase for constants. GRADES = ['F', 'D', 'C-', 'C', 'C+', 'B-', 'B', 'B+', 'A-', 'A', 'max'] GPA_OF = { 'F' : 0, 'D' : 1, 'C-' : 1.7, 'C' : 2, 'C+' : 2.3, 'B-' : 2.7, 'B' : 3, 'B+' : 3.3, 'A-' : 3.7, 'A' : 4 } """max may be written into the cuts file, and when it is there it is preserved.""" CUTS = { 'F' : 0, 'D' : None, 'C-' : None, 'C' : None, 'C+' : None, 'B-' : None, 'B' : None, 'B+' : None, 'A-' : None, 'A' : None, 'median': None, 'max': None } GRADE2INDEX = {} for i in range(len(GRADES)): GRADE2INDEX[GRADES[i]] = i # Sort methods def try_name(a): if hasattr(a, 'name'): return a.name else: return a.login # def by_name(a, b): # if hasattr(a, 'name') and hasattr(b, 'name'): # return cmp(a.name, b.name) # else: # return cmp(a.login, b.login) def try_score(a): if hasattr(a, 'score'): return a.score else: return 0 # def by_score(a, b): # if hasattr(a, 'score'): # a_score = a.score # else: # a_score = 0 # if hasattr(b, 'score'): # b_score = b.score # else: # b_score = 0 # if a_score != b_score: # return cmp(a_score, b_score) # else: # return cmp(a.login, b.login) # SORT_METHOD = { 'score' : by_score, 'name' : by_name } """ Attributes: - name - student_records, a hash of object of type Record indexed by login name - rst_name, filename of the list of students for the exam. - median, the median of the scores (starting from some score) - max_score - cuts, a hash of grade cutoffs indexed by the grade, only if there are letter grades. - denom, what to divide the scores with if it is not the default B-cut. - distr a dictionary, giving for each grade its frequency, only if there are letter grades. - grading_dir - backup_dir """ class Exam: def __init__(self, name, exam_data, grading_dir = '.', student_name = '', backup_dir = os.path.join('.','.backups')): """ Uses read_records, read_cuts, calc_grades, calc_median_above If there is a .cuts file, the grade is computed. If there is none, the grade in the file 'name' is used if present. """ self.name = name self.grading_dir = grading_dir self.backup_dir = backup_dir file = os.path.join(self.grading_dir, self.name) self.student_records = {} # warn("data for exam {}: {}".format(self.name,exam_data)) self.rst_name = exam_data.get('list','rst') if 'max' in exam_data: self.max_score = exam_data['max'] if 'denom' in exam_data: self.denom = exam_data['denom'] self.read_records(student_name = student_name) self.calc_median_above() cuts = exam_data.get('cuts', False) # warn("Exam {} cuts attribute = {}".format(self.name,cuts)) if cuts: self.cuts = CUTS.copy() if hasattr(self,'max_score'): self.cuts['max'] = self.max_score if os.path.isfile(file+'.cuts'): # print("Reading "+self.name+".cuts") self.read_cuts() self.calc_grades() else: warn(file+'.cuts'+" not found.") self.print_cuts() def get_student_list(self): return list(self.student_records.keys()) # It was important to put this into a list now, to avoid runtime error of changing # dictionary size during iterations. def get_record(self, student_name): return self.student_records.get(student_name) def get_record_list(self): return list(iter(self.student_records.values())) def exists_student(self, student_name): return student_name in self.student_records def create_student(self, student_name): # # Only the student login is given # self.student_records[student_name] = Record.Record(inp_str = student_name) return self def get_cut(self, grade): if hasattr(self, 'cuts'): cut = self.cuts.get(grade) # warn("Exam.py: cut for grade {} from exam {} is {}".format(grade,self.name,cut)) return cut else: return None def set_cut(self, grade, score): if not hasattr(self, "cuts"): self.cuts = CUTS.copy() self.cuts[grade] = score return self def set_cuts(self, new_cuts): self.cuts = new_cuts return self def get_distr(self, grade): if hasattr(self, 'distr'): return self.distr.get(grade) def get_score(self, student_name): record = self.get_record(student_name) if hasattr(record, 'score'): return record.score def set_score(self, student_name, score): if not self.exists_student(student_name): self.create_student(student_name) self.student_records[student_name].score = score def get_grade(self, student_name): record = self.get_record(student_name) if record and hasattr(record, 'grade'): return record.grade def set_grade(self, student_name, grade): if not self.exists_student(student_name): self.create_student(student_name) self.student_records[student_name].grade = grade def get_comment(self, student_name): record = self.get_record(student_name) if record and hasattr(record, 'comment'): return record.comment """ The records for exam mid are in files mid and mid.xcp File mid.xcp are exceptions. It is needed since sometimes the scores in mid are computed by a program and there are special cases not handled well by this program. So, whenever some item is defined in mid.xcp, it overrides the ones in mid. Therefore mid.xcp is read after mid. """ def read_records(self, student_name = ''): # print("Reading exam "+self.name+":") Student_records = self.student_records file = os.path.join(self.grading_dir, self.name) reading_file = file if not os.path.isfile(file): warn(file+' does not exist, using '+self.rst_name) reading_file = os.path.join(self.grading_dir, self.rst_name) try: fh = open(reading_file,"rU") # Universal readline mode, handles Mac, PC as well. except IOError as detail: die('Cannot open '+reading_file, detail) while 1: input_line = fh.readline() if not input_line: break if re.match(r'^(#|\s)\s*$', input_line): continue student_record = Record.Record(inp_str = input_line, student_name = student_name) if not student_record.login: continue Student_records[student_record.login] = student_record fh.close() xcp_file = file + '.xcp' if not os.path.exists(xcp_file): return self try: fh = open(xcp_file) except IOError as detail: die('Cannot open ' + xcp_file, detail) while 1: input_line = fh.readline() if not input_line: break if re.match(r'^(#|\s)\s*$', input_line): continue student_record = Record.Record(inp_str = input_line) if not student_record.login: continue login = student_record.login if not self.exists_student(login): Student_records[login] = student_record else: Student_records[login].update_from_record(student_record) fh.close() return self def read_cuts_file(self, file): try: fh = open(file,"rU") except IOError as detail: die('Cannot read file ' + file, detail) # print("Reading cuts file "+file+" for "+self.name) if not hasattr(self, 'cuts'): self.cuts = CUTS.copy() self.distr = {} # self.cuts['F'] = 0 # This need not be in the cut file. while 1: input_line = fh.readline() if not input_line: break if '#' == input_line[0]: continue sep_rx = r'(?:\s+|$)' mobj = re.match(r'^\s*(\S+)\s+(\d+)'+sep_rx+'(.*)', input_line) if (not mobj): continue grade, cut_s, rest = mobj.groups() self.cuts[grade] = int(cut_s) rest = rest.rstrip().lstrip() if rest: self.distr[grade] = int(rest) fh.close() return self def read_cuts(self): # # The cuts for exam mid are in files mid.cuts and mid.cuts.xcp # File mid.cuts.xcp, containing exceptions, # is needed since sometimes the cuts in mid.cuts # are computed by a program and later the teacher wants to change some # cuts manually. So, whenever some item is defined in # mid.cuts.xcp, it overrides the ones in mid. # file = os.path.join(self.grading_dir, self.name) cuts_file = file+'.cuts' self.read_cuts_file(cuts_file) xcp_file = cuts_file + '.xcp' if os.path.exists(xcp_file): self.read_cuts_file(xcp_file) return self def calc_median_above(self, from_score = 1): # # The median is calculated only among scores starting from $from_score. # Default is $from_score = 1 since students with 0 score typically # are not really there. # records = self.get_record_list() records.sort(key=try_score) size = len(records) # Find the index of the first nonzero score: for i in range(size): if not hasattr(records[i], 'score'): return self if records[i].score: break if not size or i == size: self.median = 0 else: self.median = records[int((i + size)/2)].score return self def score2grade(self, score): if None == score: return for i in range(len(GRADES) - 2, -1, -1): # The last 'grade' is max. grade = GRADES[i] cut = self.get_cut(grade) if None == cut: die('score2grade: No cut for grade '+grade+' in exam '+ self.name) if cut <= score: return grade def calc_grades(self): # Check that all cuts are defined: if not hasattr(self, 'distr'): self.distr = {} for grade in GRADES: if None == self.get_cut(grade): warn('calc_grades: No cut for grade '+grade+' in exam '+ self.name) return self self.distr[grade] = 0 student_list = self.get_student_list() for student_name in student_list: score = self.get_score(student_name) if None == score: die('calc_grades: No score for student '+student_name+' in exam '+ self.name) grade = self.score2grade(score) self.set_grade(student_name, grade) # print("exam "+self.name+",student ", student_name, ", score=", score, ", grade=", grade) self.distr[grade] += 1 return self """ Option arguments: target = sorted_by = 'name' means sorted by name (default) Other possibility: sorted by score show_student = for diagnosis only_distr means that only the scores will be printed, sorted, followed by grades if available. subset is a list of student names """ def print_records(self, target = '', student_list = 'rst', sorted_by = 'name', print_grade = True, print_name = True, show_student = '', only_distr = False, precision = 0, subset = []): if not hasattr(self, 'name'): die('The exam object has no name.') if only_distr and not target: target = self.name+'.distr' target = target or self.name+'.name' file = os.path.join(self.grading_dir, target) # Default target is self.name, default sorting is by name. try: fh = open(file, 'w') except IOError as detail: raise Exception('Cannot open'+file+' for writing', detail) if only_distr: sorted_by = 'score' records = self.get_record_list() if not records: print("record_list is empty") if sorted_by == 'score': records.sort(key=try_score) else: records.sort(key=try_name) for student_record in records: if subset and not student_record.login in subset: continue # print(student_record.login+" seen") student_record.write(fh, print_grade = print_grade, print_name = print_name, only_score = only_distr, precision = precision) # print(student_record.login+" written") if show_student and show_student == student_record.login: student_record.write(sys.stderr, print_grade = print_grade, print_name = print_name, precision=precision) return self """ Into .cuts. The median is also printed. """ def print_cuts(self): file = os.path.join(self.grading_dir, self.name) if os.path.isfile(file+'.cuts'): try: backup_file = os.path.basename(file)+'.cuts.old' backup_file = os.path.join(self.backup_dir, backup_file) shutil.copy(file+'.cuts', backup_file) except (IOError, OSError) as detail: die('Cannot back up '+file+'.cuts !', detail) try: fh = open(file+'.cuts', 'w') except (IOError, OSError) as detail: die('Cannot open '+file+'.cuts for writing!', detail) for grade in GRADES: cut = self.get_cut(grade) freq = self.get_distr(grade) if None == freq or 'max' == grade: freq_str = ' ' else: freq_str = '%3d' % freq if None == cut: fh.write('%-6s\n' % grade) else: if 'F' == grade: cut = 0 fh.write('%-6s %3d %3s\n' % (grade, cut, freq_str)) if hasattr(self, 'median'): fh.write('%-6s %3d\n' % ('median', self.median)) return self def update_from_exam(self, exam): # # Used mainly to supply the name and buid from the exam rst into an # exam that does not have them # 2020 april: or has them in a different format. # 2016 dec: Now changed to also add records from rst (with 0 scores if from rst). # So now we go through the list of the updating exam's student list instead of self's. # print("Updating "+self.name) student_list = exam.get_student_list() for student_name in student_list: exam_record = exam.get_record(student_name) if None == exam_record: # warn('student_name '+student_name+' has no record in '+exam.name) continue my_record = self.get_record(student_name) if None != my_record: my_record.update_from_record(exam_record) # Here, it is important that an empty field like comment # of rst indeed does not exist, and is not the empty string. # Otherwise, the empty string from rst will overwrite a # possibly nonempty string in the record. else: self.student_records[student_name] = exam_record if not hasattr(exam_record, 'score'): self.student_records[student_name].score = 0 def purge_using_exam(self, exam, precision = 0): # # Delete all records that are not also in exam. # The deleted records will be printed in .backups/name+'.dropped'. # # print("Purging "+self.name+" using "+exam.name) need_dropping = False student_list = self.get_student_list() for student_name in student_list: if None == exam.get_record(student_name): need_dropping = True if not need_dropping: return file = os.path.join(self.grading_dir, self.name) try: drp_file = os.path.basename(file)+'.dropped' drp_file = os.path.join(self.backup_dir, drp_file) fh = open(drp_file, 'w') except IOError as detail: die('Cannot open '+drp_file, detail) student_list = self.get_student_list() for student_name in student_list: if None == exam.get_record(student_name): self.get_record(student_name).write(fh, print_name = True, print_grade = True, precision=precision) del self.student_records[student_name] # print("Deleted "+student_name) # print("Purged exam name = "+self.name) if 'midterm-1-phd'==self.name: self.print_records(target = 'test')