Skip to content

Commit dca27a2

Browse files
committed
Use the .exam parser from the compiler to read .exam files
The Exam.source method uses the NumbasObject parser from the Numbas compiler to read the source.exam file from an exam package, so that its migrations are applied. The immediate motivation for this is to update old packages to use the new feedback settings, so that the dashboard code doesn't have to know about both versions. The parser code is copied directly into numbas_lti/examparser. It can be updated by running `make update_examparser`. The path to the numbas compiler is given by the variable NUMBAS_COMPILER_PATH
1 parent 2a13c1f commit dca27a2

5 files changed

Lines changed: 569 additions & 4 deletions

File tree

Makefile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1+
NUMBAS_COMPILER_PATH?=../compiler
2+
13
update_locales:
24
python manage.py makemessages -v0 --locale en
35
python manage.py makemessages --locale en -v0 -d djangojs -i "doc/*" -i "media/*" -i bootstrap -i vue.js -i "numbas_lti/static/jsi18n/*" -i "static/*"
46
python manage.py compilemessages -v0
57
python manage.py compilejsi18n -p numbas_lti -o numbas_lti/static/jsi18n -v0
8+
9+
update_examparser: numbas_lti/examparser/numbasobject.py numbas_lti/examparser/examparser.py numbas_lti/examparser/migrations.py
10+
11+
numbas_lti/examparser/%: $(NUMBAS_COMPILER_PATH)/bin/%
12+
cp $< $@
Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
#Copyright 2011-13 Newcastle University
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
import sys
15+
import re
16+
try:
17+
# For Python > 2.7
18+
from collections import OrderedDict
19+
except ImportError:
20+
# For Python < 2.6 (after installing ordereddict)
21+
from ordereddict import OrderedDict
22+
23+
try:
24+
basestring
25+
strcons = unicode
26+
except NameError:
27+
basestring = str
28+
strcons = str
29+
30+
class ParseError(Exception):
31+
def __init__(self,parser,message,hint=''):
32+
self.expression = parser.source[parser.cursor:parser.cursor+50]
33+
self.line = parser.source[:parser.cursor].count('\n')+1
34+
self.message = message
35+
self.hint = hint
36+
37+
def __str__(self):
38+
msg = '%s at line %s near: \n\t %s ' % (self.message,self.line,self.expression)
39+
if self.hint:
40+
msg += '\nPossible fix: '+self.hint
41+
return msg
42+
43+
class ExamParser:
44+
source = ''
45+
cursor = 0
46+
47+
#parse a string into a data structure
48+
def parse(self,source):
49+
self.source = source
50+
self.cursor = 0
51+
self.data = self.getthing()
52+
if self.source[self.cursor:].strip()!='':
53+
raise ParseError(self,"Didn't parse all input","check for unmatched brackets")
54+
55+
return self.data
56+
57+
#scan past comments
58+
def lstripcomments(self):
59+
os=self.source[self.cursor:]
60+
s = os.lstrip()
61+
s=s.lstrip() #get rid of leading whitespace
62+
63+
while s[:2]=='//':
64+
s=s[s.find('\n')+1:].lstrip()
65+
self.cursor += len(os)-len(s)
66+
67+
def stripspace(self):
68+
os = self.source[self.cursor:]
69+
s=os.lstrip(' \t\r\x0b\x0c')
70+
self.cursor += len(os)-len(s)
71+
72+
def getthing(self):
73+
self.lstripcomments()
74+
75+
f=self.source[self.cursor]
76+
77+
if f=='{': #object
78+
self.cursor+=1
79+
self.lstripcomments()
80+
81+
obj = OrderedDict()
82+
while self.cursor<len(self.source) and self.source[self.cursor]!='}':
83+
i=self.cursor
84+
namere = re.compile(r'^[\w_]*\'*$')
85+
while i<len(self.source) and self.source[i]!=':':
86+
name = self.source[self.cursor:i+1].strip()
87+
if(not namere.match(name)):
88+
raise ParseError(self,"Invalid name '%s' for an object property" % name,"check for mismatched brackets")
89+
i+=1
90+
if i==len(self.source):
91+
raise ParseError(self,"Expected a colon")
92+
93+
name = self.source[self.cursor:i].rstrip().lower()
94+
self.cursor = i+1
95+
thing = self.getthing()
96+
obj[name] = thing
97+
98+
self.stripspace()
99+
100+
if self.source[self.cursor]=='\n':
101+
self.cursor +=1
102+
self.lstripcomments()
103+
104+
elif self.source[self.cursor:self.cursor+2]=='//':
105+
self.lstripcomments()
106+
else:
107+
self.lstripcomments()
108+
if self.source[self.cursor]==',' or self.source[self.cursor]=='\n':
109+
self.cursor+=1
110+
self.lstripcomments()
111+
elif self.source[self.cursor]=='}':
112+
break
113+
else:
114+
raise ParseError(self,'Expected either } or , in object definition')
115+
if self.cursor == len(self.source):
116+
raise ParseError(self,'Expected a } to close an object')
117+
118+
self.cursor +=1
119+
return obj
120+
121+
elif f=='[': #array
122+
self.cursor += 1
123+
self.lstripcomments()
124+
125+
arr=[]
126+
while self.cursor<len(self.source) and self.source[self.cursor]!=']':
127+
thing = self.getthing()
128+
arr.append(thing)
129+
130+
self.stripspace()
131+
132+
if self.source[self.cursor]=='\n':
133+
self.cursor+=1
134+
self.lstripcomments()
135+
elif self.source[self.cursor:self.cursor+2]=='//':
136+
self.lstripcomments()
137+
else:
138+
self.lstripcomments()
139+
if self.source[self.cursor]==',':
140+
self.cursor +=1
141+
elif self.source[self.cursor]==']':
142+
break
143+
else:
144+
raise ParseError(self,"Expected either , or ] in array definition")
145+
if self.cursor == len(self.source):
146+
raise ParseError(self,'Expected a ] to end an array')
147+
self.cursor +=1
148+
return arr
149+
150+
elif f=='"': #string literal - double quotes
151+
if self.source[self.cursor:self.cursor+3]=='"""': #triple-quoted string
152+
i=self.cursor+3
153+
while i<len(self.source)-2 and self.source[i:i+3]!='"""':
154+
i+=1
155+
while i<len(self.source)-3 and self.source[i+3]=='"': #grab extra double-quotes which are part of the string. e.g. """"hi"""" parses as the string "hi", with double-quotes included
156+
i+=1
157+
if i==len(self.source)-2:
158+
raise ParseError(self,'Expected """ to end string literal')
159+
string = self.source[self.cursor+3:i]
160+
self.cursor = i+3
161+
else:
162+
i=self.cursor+1
163+
while i<len(self.source) and self.source[i]!='"':
164+
i+=1
165+
if i==len(self.source):
166+
raise ParseError(self,'Expected " to end string literal')
167+
string = self.source[self.cursor+1:i]
168+
self.cursor = i+1
169+
return string
170+
elif f=="'": #string literal - single quotes
171+
if self.source[self.cursor:self.cursor+3]=="'''": #triple-quoted string
172+
i=self.cursor+3
173+
while i<len(self.source)-2 and self.source[i:i+3]!="'''":
174+
i+=1
175+
while i<len(self.source)-3 and self.source[i+3]=="'": #grab extra quotes which are part of the string. e.g. ''''hi'''' parses as the string "hi", with quotes included
176+
i+=1
177+
if i==len(self.source)-2:
178+
raise ParseError(self,"Expected ''' to end string literal")
179+
string = self.source[self.cursor+3:i]
180+
self.cursor = i+3
181+
else:
182+
i=self.cursor+1
183+
while i<len(self.source) and self.source[i]!="'":
184+
i+=1
185+
if i==len(self.source):
186+
raise ParseError(self,"Expected ' to end string literal")
187+
string = self.source[self.cursor+1:i]
188+
self.cursor = i+1
189+
return string
190+
else: #undelimited literal
191+
i=self.cursor
192+
while i<len(self.source) and self.source[i] not in ']}\n,:' and self.source[i:i+2]!='//':
193+
i+=1
194+
195+
v=self.source[self.cursor:i].strip()
196+
l=v.lower()
197+
if is_number(v):
198+
if is_int(v):
199+
v=int(v)
200+
else:
201+
v=float(v)
202+
elif l=='true':
203+
v=True
204+
elif l=='false':
205+
v=False
206+
207+
self.cursor = i
208+
return v
209+
210+
def printdata(data,ntabs=0):
211+
tabs = ntabs*'\t'
212+
if type(data)==dict or type(data)==OrderedDict:
213+
s=''
214+
first=True
215+
for x in data.keys():
216+
if not first:
217+
s+='\n'
218+
if type(data[x])==dict or type(data[x])==list:
219+
s+='\n'
220+
s+=x+': '+printdata(data[x],ntabs+1)
221+
first=False
222+
if '\n' in s:
223+
s='{\n'+s+'\n'+'}'
224+
else:
225+
s='{'+s+'}'
226+
return s
227+
elif type(data)==list:
228+
s=''
229+
first=True
230+
for x in data:
231+
if not first:
232+
s+=', '
233+
if '\n' in s:
234+
s+='\n'
235+
if type(x)==dict or type(x)==list:
236+
s+='\n'
237+
s+=printdata(x,ntabs+1)
238+
first=False
239+
if '\n' in s:
240+
s='[\n'+s+'\n'+']'
241+
else:
242+
s='['+s+']'
243+
return s
244+
else:
245+
if data=='infinity':
246+
return '"infinity"'
247+
if '"' in strcons(data) and not isinstance(data,basestring):
248+
print("Unexpected type: "+str)
249+
250+
if isinstance(data,basestring) and ('\n' in data or '}' in data or ']' in data or ',' in data or '"' in data or "'" in data or ':' in data or '//' in data):
251+
if '"' in data:
252+
return '"""'+data+'"""'
253+
else:
254+
return '"'+data+'"'
255+
elif isinstance(data,basestring) and data.strip()=='':
256+
return "'"+data+"'"
257+
else:
258+
return strcons_fix(data)
259+
260+
261+
#utility functions
262+
263+
"""Cast data to string. Forces fixed precision output of floats, instead of scientific notation"""
264+
def strcons_fix(data):
265+
if (data is True) or (data is False):
266+
out=data
267+
elif is_int(data):
268+
out = '%i' % int(data)
269+
elif is_number(data):
270+
out = '%.14f' % float(data)
271+
out = re.sub(r'(\.\d*[1-9])0*$','\g<1>',out)
272+
else:
273+
out=data
274+
return strcons(out)
275+
276+
def is_number(s):
277+
try:
278+
l = s.lower()
279+
if l=='infinity' or l=='-infinity':
280+
return False
281+
except AttributeError:
282+
pass
283+
try:
284+
float(s)
285+
return True
286+
except (ValueError,OverflowError,TypeError):
287+
return False
288+
289+
def is_int(s):
290+
try:
291+
int(s)
292+
return int(s)==s
293+
except (ValueError,OverflowError,TypeError):
294+
return False
295+
296+
#make sure string s has exactly n copies of c at the start
297+
def pad_left(s,c,n):
298+
return re.sub('^'+c+'*',c*n,s)
299+
300+
def __demo():
301+
source='''
302+
//comment
303+
{ //comment!
304+
a: """ "hi" //comment
305+
said the man""" //comment
306+
b: howdy, c: there //comment
307+
d: "sailor,man" //comment asd
308+
309+
e: geoff //comment
310+
f: [eggs , beans,{a:hi}]
311+
}
312+
'''
313+
#source=open('testExam.exam').read()
314+
parser = ExamParser()
315+
try:
316+
data = parser.parse(source)
317+
source=printdata(data)
318+
data=parser.parse(source)
319+
print(printdata(data))
320+
except ParseError as err:
321+
print('Parse error: ', str(err))
322+
323+
if __name__ == '__main__':
324+
__demo()

0 commit comments

Comments
 (0)