def GenerateMarksFromCsv(*, annotate_file='annotate.csv', project='..', weights:'Tuple[int]', output='marks.csv'): """ From <annotate_file> (annotate.csv) (two columns: src, annot) And current db in <project> (default '..') Generates <output_file> (marks.csv) (two columns: matricule, mark_on_10) annot contains characters in the set 'vxf-012345789' <weights> is a list of positive int """ # p.add_argument('--annotate_file', default='annotate.csv') # p.add_argument('--project', default='..') # p.add_argument('--weights', type=int, nargs='+') # p.add_argument('-o', '--output', default='marks.csv') connection = sqlite3.connect(project + '/data/capture.sqlite') Assoc = dict(connection.execute('select src, student from capture_page')) Annot = dict(csv.reader(open('annotate.csv'))) Marks = { Assoc['%PROJET/scans/' + src]: sum(compute_mark_list(annot, weights)) for src, annot in Annot.items() } with OutFile(output, 'w') as out: csv.writer(out).writerows(Marks.items())
from generate_utils import OutFileGreen as OutFile except ImportError: OutFile = open with open(txt_file) as file: string = file.read() R = Re('\d?\d:\d+\d+') A = R.split(string) B = R.findall(string) if not (A and A[0].strip() == ''): raise ValueError("Must begin with a time") assert len(A) == 1 + len( B), "the programmer did not understand re.split and re.findall" bits = [] for i in range(len(B)): b, a = A[i + 1], B[i] x, y = a.split(':') x, y = int(x), int(y) time = x * 60 + y title = html.escape(b) if with_times: title = a + ' ' + title bits.append(f'<li><a href="{baselink}&t={time}">{title}</a></li>') with OutFile(new_name, 'w') as out: out.write('<ul>{}</ul>'.format('\n'.join(bits)))
def GenerateSrcList(output:'.csv', *, project:'generateurAMC_A', page=None): c = sqlite3.connect(project + '/data/capture') L = src.replace('%PROJET', 'project') for src in c.execute( ''' select src from capture_page ''' if page is None else ''' select src from capture_page where page={}'''.format(int(page))) with OutFile(output) as out: out.write('\n'.join(L)) class ExamInfo: SERIES:('A', 'B') PROJECT_NAME:'path to project, may contain {serie}' = 'generateurAMC_{serie}' OPEN_QUESTION_SOURCE = {} # All from the marks ticks (from 0 to 10) on the paper (FromScannedAnnotationsAndMark) class PHYSS1001_JANVIER_2017_2018(ExamInfo): SERIES = ('A', 'B') PROJECT_NAME = 'generateurAMC_{serie}' ANSWERS_= { # list of [value, min, max, -points minus] 'QF5a': [ StandardPossibleAnswer('7.81e-1', '7.71e-1', '7.81e-1', 0), StandardPossibleAnswer('1.56', '1.54', '1.58', -1) ], 'QF6a': [ RulesStandardPossibleAnswer('-3.32e-9', '3.22e-9', '3.42e-9', 0, ['SignMinus1']), RulesStandardPossibleAnswer('-375', '371', '379', -1, ['SignMinus1']), ], 'QF7a': [ StandardPossibleAnswer('1.33e-8', '1.23e-8', '1.43e-8', 0), StandardPossibleAnswer('5.32e-8', '5.3e-8', '5.34e-8', -3), StandardPossibleAnswer('2.66e-8', '2.64e-8', '2.68e-8', -3) ], 'QF8a': [ StandardPossibleAnswer('2.5', '2.4', '2.6', 0), ], 'QF5b': [ StandardPossibleAnswer('1.53', '1.43', '1.63', 0), StandardPossibleAnswer('3.06', '3.04', '3.08', -1) ], 'QF6b': [ RulesStandardPossibleAnswer('-5.53e-9', '5.43e-9', '5.63e-9', 0, ['SignMinus1']), RulesStandardPossibleAnswer('625', '621', '629', -1, ['SignMinus1']), ], 'QF7b': [ StandardPossibleAnswer('1.18e-8', '1.08e-8', '1.28e-8', 0), StandardPossibleAnswer('2.36e-8', '2.34e-8', '2.38e-8', -3), StandardPossibleAnswer('3.54e-8', '3.52e-8', '3.56e-8', -3), StandardPossibleAnswer('1.77e-8', '1.75e-8', '1.79e-8', -3), ], 'QF8b': [ StandardPossibleAnswer('1.41', '1.31', '1.51', 0), ], } OPEN_QUESTION_SOURCE = { # 'QO2': ReadFromMatriculeMarkCsv('q2-marks.csv', 'QO2'), # ReadFromAnnotate(''), } class PHYSS1001_JUIN_2017_2018(ExamInfo): SERIES = ('A', 'B') PROJECT_NAME = 'generateurAMC_{serie}' ANSWERS = { 'QF7a': [ { 'min': '4.90e-4', 'exact':'5.00e-4', 'max': '5.10e-4', 'minus': 0, }, ], 'QF8a': [ { 'min': '2.43e1', 'exact': '2.53e1', 'max': '2.63e1', 'minus': 0, }, ], 'QF9a': [ { 'exact': '9.55e4', 'min': '9.45e4', 'max': '9.65e4', 'minus': 0, }, ], 'QF10a': [ { 'min': '6.15e3', 'exact': '6.25e3', 'max': '6.35e3', 'minus': 0, }, ], 'QF11a': [ { 'min': '2.17e3', 'exact': '2.27e3', 'max': '2.37e3', 'minus': 0 }, ], 'QF12a': [ { 'min': '5.61e0', 'exact': '5.71e0', 'max': '5.81e0', 'minus': 0 }, ], 'QF7b': [ { 'min': '9.45e4', 'exact': '9.55e4', 'max': '9.65e4', 'minus': 0, }, ], 'QF8b': [ { 'min': '2.43e1', 'exact': '2.53e1', 'max': '2.63e1', 'minus': 0 }, ], 'QF9b': [ { 'min': '4.90e-4', 'exact': '5.00e-4', 'max': '5.10e-4', 'minus': 0 }, ], 'QF10b': [ { 'min': '5.61e0', 'exact': '5.71e0', 'max': '5.81e0', 'minus': 0 }, ], 'QF11b': [ { 'min': '2.17e3', 'exact': '2.27e3', 'max': '2.37e3', 'minus': 0 }, ], 'QF12b': [ { 'min': '6.15e3', 'exact': '6.25e3', 'max': '6.35e3', 'minus': 0 }, ], } OPEN_QUESTION_SOURCE = { 'QO1': ReadFromMatriculeMarkCsv(['points-P2-a-Q1-results.csv', 'points-P2-b-Q1-results.csv']), # others are written in AMC, ReadFromAnnotate } class PHYSS1001_JUINRATTR_2017_2018(ExamInfo): SERIES = ('A', ) PROJECT_NAME = 'generateurAMC_{serie}' # from list to dict with optional keys ANSWERS_ = { # list of [value, min, max, -points minus] 'QF7a': [ StandardPossibleAnswer('0', '0', '0', 0), ], 'QF8a': [ StandardPossibleAnswer('6.53e6', '6.43e6', '6.63e6', 0), ], 'QF9a': [ StandardPossibleAnswer('2.72e7', '2.62e7', '2.82e7', 0), ], 'QF10a': [ StandardPossibleAnswer('2.83e-7', '2.73e-7', '2.93e-7', 0), ], } class PHYSS1001_AOUT_2017_2018(ExamInfo): SERIES = ('A', 'B') PROJECT_NAME = 'generateurAMC_{serie}' ANSWERS_ = { # list of [value, min, max, -points minus] 'QF7a': [ StandardPossibleAnswer('1.01e3', '0.91e3', '1.11e3', 0), StandardPossibleAnswer('6.74e3', '6.64e3', '6.84e3', -2) ], 'QF8a': [ StandardPossibleAnswer('9.42e-5', '9.32e-5', '9.52e-5', 0), ], 'QF9a': [ StandardPossibleAnswer('1.42e-8', '1.32e-8', '1.52e-8', 0), ], 'QF10a': [ StandardPossibleAnswer('3.54e0', '3.44e0', '3.64e0', 0), ], 'QF11a': [ StandardPossibleAnswer('1.54e4', '1.44e4', '1.64e4', 0), StandardPossibleAnswer('8.88e3', '8.78e3', '8.98e3', -2), ], 'QF12a': [ StandardPossibleAnswer('2.99e-9', '2.89e-9', '3.09e-9', 0), ], 'QF7b': [ StandardPossibleAnswer('7.79e2', '7.69e2', '7.89e2', 0), StandardPossibleAnswer('5.80e3', '5.70e3', '5.90e3', -2) ], 'QF8b': [ StandardPossibleAnswer('1.65e-4', '1.55e-4', '1.75e-4', 0), ], 'QF9b': [ StandardPossibleAnswer('1.02e-8', '0.92e-8', '1.22e-8', 0), ], 'QF10b': [ StandardPossibleAnswer('4.71e0', '4.61e0', '4.81e0', 0), ], 'QF11b': [ StandardPossibleAnswer('1.28e4', '1.18e4', '1.38e4', 0), StandardPossibleAnswer('7.40e3', '7.30e3', '7.50e3', -2), ], 'QF12b': [ StandardPossibleAnswer('4.12e-9', '4.02e-9', '4.22e-9', 0), ], } OPEN_QUESTION_SOURCE = { # 'QO2': ReadFromMatriculeMarkCsv('q2-marks.csv', 'QO2'), # ReadFromAnnotate(''), } def compute_mark_list(note, points_distrib:(3,1,2,2,1,1)): """ with points_distrib=(3,1,2,2,1,1): if note == 'vxvvxx' yields 3,0,2,2,0,0 if note == 'vxvv' yields 3,0,2,2,0,0 if note == 'fx-vxx' yields 2,0,1,2,0,0 # - means 1, f means 2 if note == '2x1vxx' yields 2,0,1,2,0,0 # can give number if note == '5x1vxx' -> ValueError if note == 'vvvvvvv' -> ValueError """ points = (3,1,2,2,1,1) assert sum(points_distrib) == 10 n = len(points_distrib) assert len(note) <= n, note assert set(note) <= set('fvx-0123456789'), "Unrecognized characters: {}".format(set(note) - set('fvx-0123456789')) exp = note + (n - len(note)) * 'x' assert len(exp) == n for n,p in zip(exp, points_distrib): if n == 'v': yield p elif n == 'x': yield 0 elif n in tuple('0123456789'): if int(n) > p: raise ValueError(f'Cannot have {n} points when maximum is {p}') yield int(n) elif n == 'f': if not p > 2: raise ValueError(f'Cannot have "f" when line has {p} points') yield 2 elif n == '-': if p == 1: warning(f'Should not have "-" when line has {p} points. On {note}') if p == 0: raise ValueError(f'Cannot have "-" when line has {p} points. On {note}') yield 1 def GenerateMarksFromCsv(*, annotate_file='annotate.csv', project='..', weights:'Tuple[int]', output='marks.csv'): """ From <annotate_file> (annotate.csv) (two columns: src, annot) And current db in <project> (default '..') Generates <output_file> (marks.csv) (two columns: matricule, mark_on_10) annot contains characters in the set 'vxf-012345789' <weights> is a list of positive int """ # p.add_argument('--annotate_file', default='annotate.csv') # p.add_argument('--project', default='..') # p.add_argument('--weights', type=int, nargs='+') # p.add_argument('-o', '--output', default='marks.csv') connection = sqlite3.connect(project + '/data/capture.sqlite') Assoc = dict(connection.execute('select src, student from capture_page')) Annot = dict(csv.reader(open('annotate.csv'))) Marks = { Assoc['%PROJET/scans/' + src]: sum(compute_mark_list(annot, weights)) for src, annot in Annot.items() } with OutFile(output, 'w') as out: csv.writer(out).writerows(Marks.items()) def ComputeMarks(exam_info, *, OUT_FILE='all_marks'): qf_answers = exam_info.ANSWERS SERIES = exam_info.SERIES PROJECT_NAME = exam_info.PROJECT_NAME open_question_source = exam_info.OPEN_QUESTION_SOURCE assert splitext(OUT_FILE)[1] == '', "please do not provide any extension" assert len(set(map(str.lower, SERIES))) == len(SERIES), f"no duplicates in series, got {SERIES}" # qf_answers assert all(len(L) > 0 for L in qf_answers.values()) assert all(isinstance(X, dict) for L in qf_answers.values() for X in L) assert all(X.get('minus', 0) == 0 for L in qf_answers.values() for i,X in enumerate(L) if i == 0) assert all(X.get('minus', 0) <= 0 for L in qf_answers.values() for X in L) assert all(isinstance(X[key], (Decimal, str)) for L in qf_answers.values() for X in L for key in ('exact', 'min', 'max') if key in X) ## convert to Decimal for latexname, L in qf_answers.items(): for X in L: for key in ('exact', 'min', 'max'): if key in X: X[key] = Decimal(X[key]) ## add min and max if not there for latexname, L in qf_answers.items(): for X in L: assert {'min', 'max'} <= X.keys(), f"no minmax in question {latexname}" ## add minus for first question if not there for latexname, L in qf_answers.items(): for i,X in enumerate(L): if i == 0: if 'minus' not in X: X['minus'] = 0 assert all(isinstance(X[key], Decimal) for L in qf_answers.values() for X in L for key in ('exact', 'min', 'max') if key in X) assert all(X[key] >= 0 for L in qf_answers.values() for X in L for key in ('min', 'max') if key in X) # TODO: assert no overlap : assert all(map(no_overlap, qf_answers.values())) # should move in qf_answers qf_special_treatment = { 'QF5a': {}, 'QF6a': {}, # {'SignMinus1'}, 'QF7a': {}, 'QF8a': {}, 'QF9a': {}, 'QF9a': {}, 'QF10a': {}, 'QF11a': {}, 'QF5b': {}, 'QF6b': {}, # {'SignMinus1'}, 'QF7b': {}, 'QF8b': {}, } for k in qf_special_treatment: if not isinstance(qf_special_treatment[k], set): qf_special_treatment[k] = set(qf_special_treatment[k]) assert all(X <= {'SignMinus1'} for X in qf_special_treatment.values()) data_marks = [] errors = [] with CSVOrXLWriter(OUT_FILE + '_questions', print_created=True) as writer: writer.writerow(('COPIE','MATRICULE','QUESTION','MARK','ANNOTATION','COMMENTS')) for serie in SERIES: proj = PROJECT_NAME.format(serie=serie) dbs = {name:sqlite3.connect(f'{proj}/data/{name}.sqlite') for name in ('layout', 'association', 'capture')} xmldoc = XmlElement.parse(f'{proj}/options.xml') try: seuil = float(xmldoc.find('seuil').text) except: seuil = 0.35 warning(f'serie {serie}: seuil not found in xml, default seuil {seuil} used') Dict = DictCollection() AmcStudentId, Matricule = Dict.keylist('AmcStudentId', 'Matricule') AmcQuestionId, LatexQuestionName = Dict.keylist('AmcQuestionId', 'LatexQuestionName') Dict[AmcQuestionId, 'to', LatexQuestionName] = dict_int_key( dbs['layout'].execute('''select question, name from layout_question''')) Dict[AmcStudentId, 'to', Matricule] = { int(student): int(auto or manual) for student, auto, manual in dbs['association'].execute('select student, auto, manual from association_association') } for latexname in Dict[AmcQuestionId, 'to', LatexQuestionName].values(): for exam, matricule in Dict[AmcStudentId, 'to', Matricule].items(): examfull = "{}{}".format(serie, exam) comments = '' qf_match = StandardNames.QF_DIGITS.fullmatch(latexname) if qf_match and qf_match.group('part') == 'digits': # latexname = QF5digits m = qf_match.groupdict() basename = StandardNames.QF_DIGITS_FORMAT(part='', num=m['num'], serie=m['serie']) # basename = QF5a info = QFInfo(Dict, dbs, seuil, exam, basename) try: values = info.parseQF() assert all(isinstance(values[x], Decimal) for x in ('value', 'mantissa', 'exp')) value = values['value'] # values['mantissa'], values['exp'], values['sign'] mark = 0 for X in qf_answers[basename]: rules = X.get('rules', ()) answer, themin, themax, minus = (X[key] for key in ('exact', 'min', 'max', 'minus')) answer_sign = (0 if answer == 0 else 1 if answer > 0 else -1) if themin <= abs(value) <= themax: m = 5 elif any(themin * Decimal(10) ** i <= abs(value) <= themax * Decimal(10) ** i for i in irange(-20,20)): m = 3 else: m = 0 if 'SignMinus1' in rules and values['sign'] != answer_sign: m -= 1 if minus: assert minus < 0 m += minus if m < 0: m = 0 mark = max(m, mark) if mark: break # TakeFirstAnswerGivingMarks comments = (values, qf_answers[basename]) annotations = [] # TODO ? except QFInfo.NoMantissa: mark = 0 except QFInfo.MultipleTicksInColumn: warning(examfull, matricule, basename, 'MultipleTicksInColumn') mark = 0 elif StandardNames.QO.fullmatch(latexname): basename = latexname if latexname not in open_question_source or isinstance(open_question_source[latexname], FromScannedAnnotationsAndMark): try: info = QOInfo(Dict, dbs, seuil, exam, latexname) mark = info.correctionValue() annotations = info.correctionCommentsList() except QOInfo.Error as e: error(examfull, matricule, basename, e.__class__.__name__) errors.append(e) continue else: fmt = open_question_source[latexname] if isinstance(fmt, ReadFromMatriculeMarkCsv): if not fmt.isread: fmt.read() try: mark = fmt.Matricule(matricule).to(fmt.Mark) except KeyError: e = KeyError(f'{matricule} not found in {fmt.csv_files}') error(examfull, matricule, basename, e.__class__.__name__) errors.append(e) continue annotations = [] # TODO (code already done in send_mail) else: error("{}{}".format(serie, exam), matricule, latexname, 'UnknownOpenQuestionSource') errors.append(UnknownOpenQuestionSource()) continue else: continue writer.writerow((examfull, matricule, basename, mark, ''.join(chr(ord('A') + i) for i in annotations), str(comments))) data_marks.append((examfull, matricule, basename, mark)) if errors: error('At least one error occured, stopping here.') return # generate pretty xlsx # TODO: make it work for series reductions = {} for serie in SERIES: for examfull, matricule, basename, mark in data_marks: m = StandardNames.QF_SERIE.fullmatch(basename) if m: m.groupdict().keys() == {'num', 'serie'} reduced_name = StandardNames.QF_SERIE_FORMAT(num=m.group('num'), serie='') reductions[basename] = reduced_name all_questions = set() student_info = {} for examfull, matricule, basename, mark in data_marks: if matricule not in student_info: student_info[matricule] = { 'exam': examfull, 'questions': {}, } else: assert student_info[matricule]['exam'] == examfull, f"Student {matricule} has mutiple exams {student_info[matricule]['exam']}, {examfull}!" student_info[matricule]['questions'][basename] = mark all_questions.add(basename) # not mandatory if len(SERIES) <= 1: assert all(student_info[matricule]['questions'].keys() == all_questions for matricule in student_info) def sort_key(basename): first = (1 if basename.startswith('QO') else 2 if basename.startswith('QF') else 3) digits = Re('\d+').findall(basename) second = int(digits[0]) if digits else 1000 return first, second def sorted_by_key(it): return sorted(it, key=sort_key) def only(it): L = list(it) if len(L) != 1: raise ValueError(str(L)) return L[0] def only_or_default(it, *, default=None): L = list(it) if len(L) > 1: raise ValueError(str(L)) if len(L) == 0: return default return L[0] all_questions_without_series = sorted_by_key(set(reductions.get(q,q) for q in all_questions)) all_possible_questions = set(all_questions_without_series) & set(reductions.values()) with CSVOrXLWriter(OUT_FILE + '_grid', print_created=True) as writer: firstrow = ['MATRICULE', 'EXAM'] + list(all_questions_without_series) writer.writerow(firstrow) for matricule in sorted(map(int, student_info)): writer.writerow( [matricule, student_info[matricule]['exam']] + [ student_info[matricule]['questions'][question_not_reduced] for question in all_questions_without_series for question_not_reduced in [ question if question in student_info[matricule]['questions'] else only(p for p,q in reductions.items() if q == question and p in student_info[matricule]['questions']) ] ]) if __name__ == '__main__': import argparse parser = argparse.ArgumentParser() # subcommand GenerateAnswers, GenerateSrcList, DoAssociationAuto, ComputeMarks, GenerateMarksFromCsv args = parser.parse_args()
name=f, transinfo=''' ''', postnav='' if not os.path.isfile(pdf_name) else ''' <a href="{}">#pdf</a> '''.format(pdf_name), nav='' if not m else ''' <a class="keephash" href="{url_prev}"><img style="width:24px; height:24px; vertical-align: middle;" src="prev.png"/></a> <a class="keephash" href="{url_next}"><img style="width:24px; height:24px; vertical-align: middle;" src="next.png"/></a> <a class="keephash" href="{url_next}">#{name}</a> '''.format( name=f, type=typename, url_prev='{}.html'.format(all_grouped[typename].get( (num - 1, t), 'index')), url_next='{}.html'.format(all_grouped[typename].get( (num + 1, t), 'index')), )) try: with open(f + '.html', 'r') as fl: before = fl.read() except: before = '' if res != before: modifs.append(f) with OutFile(f + '.html') as fl: fl.write(res) print('generate_html:', len(modifs), 'file' + 's' * (len(modifs) != 1) + ' modified' + ':' * bool(modifs), ' '.join(modifs))
slides = set_union(info for layer, info in all_infos) Format = ("{}.state-{}.svg" if args.state_in_filename else "{}.{}.svg") for layer in layers: # should only be with the layers with {} tag layer.setAttribute( 'style', layer.getAttribute('style').replace('display:none', 'display:inline').replace( 'display: none', 'display: inline')) for slide in slides: if args.verbose: bits = ['Slide {!r}'.format(slide)] new = OutFile(Format.format(svg_filename, slide)) with new as f: for layer, info in all_infos: if args.verbose: bits.append(str((slide, layer_names[layer], slide in info))) if slide not in info: root.removeChild(layer) f.write(root.toxml()) for layer, info in reversed(all_infos): if slide not in info: root.insertBefore(layer, nexts[layer]) if args.verbose: print_info(', '.join(bits))
fileinfo['name'][lang] if has_info_name else simple_name) if os.path.isfile(filename): with open(filename, 'r') as fo: prev_str = fo.read() else: prev_str = '' next_str = RE_TR_TAG.sub(lambda m: spliti(m.group(1), TR_TAG_SEP, i, 'NOT TRANSLATED ' + spliti(m.group(1), TR_TAG_SEP, 0)), s) # ornone = lambda x, y: y if x is None else x # next_str = RE_TR.sub(lambda m: ornone(m.group(i+1), m.group(1)), s) if prev_str != next_str: modified.append(filename) with OutFile(filename) as fo: fo.write(next_str) if i == 0: new = OutFile(basename + ending) new.unlock() shutil.copy(filename, new.filename) new.lock() info('Copy', filename, '->', '(read only)', new.filename) del new # TODO: redirects/symlink # in: trucs.multilang_as_stuffs.txt # trucs.en.txt -> stuff.txt # stuff.fr.txt -> trucs.txt # trucs.fr.txt -> trucs.txt
if args.file: assert args.file.endswith('.txt'), "must be a txt file" import sys import re try: from generate_utils import OutFileGreen as OutFile except ImportError: OutFile = open with (open(args.file) if args.file else sys.stdin) as f: L = [x.strip('\n') for x in f] for i in range(len(L)): try: a,b,c = re.compile('(\d+) (\d+)(.*)').fullmatch(L[i]).groups() except AttributeError: print('Line {!r} did not match'.format(L[i])) continue L[i] = a.zfill(2) + ':' + b.zfill(2) + c transform = ((lambda x: x.replace('<', '‹').replace('>', '›')) if args.replace_lt else lambda x:x) with (OutFile(args.file + '.index', 'w') if args.file else sys.stdout) as f: f.write(transform('\n'.join(L)))
#!/usr/bin/env python3 import re from generate_utils import OutFile with open('cv.html') as f: s = f.read() R = re.compile('{{(.*?)\|(.*?)}}', re.DOTALL) for i, lang in enumerate(('fr', 'en')): with OutFile('cv.{}.html'.format(lang), 'w') as f: f.write(R.sub(lambda m: m.group(i + 1), s))
ulend='</ul>' if path != '.' else '', cls='root' if path == '.' else '', ind=indent * ' ', name=os.path.basename(path) if not RE0.match(path) else os.path.basename(path[:-3]), sub='\n'.join( map(((1 + indent) * ' ' + '{}').format, map( partial(content, indent=indent + 1), filter( accepted, map( partial(os.path.join, path), partial(sorted, key=key)(os.listdir(path)))))))) if __name__ == '__main__': with open('template.html') as f: template = f.read() with open('plan.svg') as f: plan_svg_content = f.read() with OutFile('index.html') as f: f.write( template.replace('{% include "plan.svg" %}', plan_svg_content).replace( '{{ list }}', content('.', indent=3) + '</section>'))