1 | #!/usr/bin/env python |
---|
2 | |
---|
3 | USAGE = '''Usage: testasciidoc.py [OPTIONS] COMMAND |
---|
4 | |
---|
5 | Run AsciiDoc conformance tests specified in configuration FILE. |
---|
6 | |
---|
7 | Commands: |
---|
8 | list List tests |
---|
9 | run [NUMBER] [BACKEND] Execute tests |
---|
10 | update [NUMBER] [BACKEND] Regenerate and update test data |
---|
11 | |
---|
12 | Options: |
---|
13 | -f, --conf-file=CONF_FILE |
---|
14 | Use configuration file CONF_FILE (default configuration file is |
---|
15 | testasciidoc.conf in testasciidoc.py directory) |
---|
16 | --force |
---|
17 | Update all test data overwriting existing data''' |
---|
18 | |
---|
19 | |
---|
20 | __version__ = '0.1.1' |
---|
21 | __copyright__ = 'Copyright (C) 2009 Stuart Rackham' |
---|
22 | |
---|
23 | |
---|
24 | import os, sys, re, difflib |
---|
25 | |
---|
26 | if sys.platform[:4] == 'java': |
---|
27 | # Jython cStringIO is more compatible with CPython StringIO. |
---|
28 | import cStringIO as StringIO |
---|
29 | else: |
---|
30 | import StringIO |
---|
31 | |
---|
32 | import asciidocapi |
---|
33 | |
---|
34 | |
---|
35 | BACKENDS = ('html4','xhtml11','docbook','wordpress','html5') # Default backends. |
---|
36 | BACKEND_EXT = {'html4':'.html', 'xhtml11':'.html', 'docbook':'.xml', |
---|
37 | 'wordpress':'.html','slidy':'.html','html5':'.html'} |
---|
38 | |
---|
39 | |
---|
40 | def iif(condition, iftrue, iffalse=None): |
---|
41 | """ |
---|
42 | Immediate if c.f. ternary ?: operator. |
---|
43 | False value defaults to '' if the true value is a string. |
---|
44 | False value defaults to 0 if the true value is a number. |
---|
45 | """ |
---|
46 | if iffalse is None: |
---|
47 | if isinstance(iftrue, basestring): |
---|
48 | iffalse = '' |
---|
49 | if type(iftrue) in (int, float): |
---|
50 | iffalse = 0 |
---|
51 | if condition: |
---|
52 | return iftrue |
---|
53 | else: |
---|
54 | return iffalse |
---|
55 | |
---|
56 | def message(msg=''): |
---|
57 | print >>sys.stderr, msg |
---|
58 | |
---|
59 | def strip_end(lines): |
---|
60 | """ |
---|
61 | Strip blank strings from the end of list of strings. |
---|
62 | """ |
---|
63 | for i in range(len(lines)-1,-1,-1): |
---|
64 | if not lines[i]: |
---|
65 | del lines[i] |
---|
66 | else: |
---|
67 | break |
---|
68 | |
---|
69 | def normalize_data(lines): |
---|
70 | """ |
---|
71 | Strip comments and trailing blank strings from lines. |
---|
72 | """ |
---|
73 | result = [ s for s in lines if not s.startswith('#') ] |
---|
74 | strip_end(result) |
---|
75 | return result |
---|
76 | |
---|
77 | |
---|
78 | class AsciiDocTest(object): |
---|
79 | |
---|
80 | def __init__(self): |
---|
81 | self.number = None # Test number (1..). |
---|
82 | self.name = '' # Optional test name. |
---|
83 | self.title = '' # Optional test name. |
---|
84 | self.description = [] # List of lines followoing title. |
---|
85 | self.source = None # AsciiDoc test source file name. |
---|
86 | self.options = [] |
---|
87 | self.attributes = {} |
---|
88 | self.backends = BACKENDS |
---|
89 | self.datadir = None # Where output files are stored. |
---|
90 | self.disabled = False |
---|
91 | |
---|
92 | def backend_filename(self, backend): |
---|
93 | """ |
---|
94 | Return the path name of the backend output file that is generated from |
---|
95 | the test name and output file type. |
---|
96 | """ |
---|
97 | return '%s-%s%s' % ( |
---|
98 | os.path.normpath(os.path.join(self.datadir, self.name)), |
---|
99 | backend, |
---|
100 | BACKEND_EXT[backend]) |
---|
101 | |
---|
102 | def parse(self, lines, confdir, datadir): |
---|
103 | """ |
---|
104 | Parse conf file test section from list of text lines. |
---|
105 | """ |
---|
106 | self.__init__() |
---|
107 | self.confdir = confdir |
---|
108 | self.datadir = datadir |
---|
109 | lines = Lines(lines) |
---|
110 | while not lines.eol(): |
---|
111 | l = lines.read_until(r'^%') |
---|
112 | if l: |
---|
113 | if not l[0].startswith('%'): |
---|
114 | if l[0][0] == '!': |
---|
115 | self.disabled = True |
---|
116 | self.title = l[0][1:] |
---|
117 | else: |
---|
118 | self.title = l[0] |
---|
119 | self.description = l[1:] |
---|
120 | continue |
---|
121 | reo = re.match(r'^%\s*(?P<directive>[\w_-]+)', l[0]) |
---|
122 | if not reo: |
---|
123 | raise (ValueError, 'illegal directive: %s' % l[0]) |
---|
124 | directive = reo.groupdict()['directive'] |
---|
125 | data = normalize_data(l[1:]) |
---|
126 | if directive == 'source': |
---|
127 | if data: |
---|
128 | self.source = os.path.normpath(os.path.join( |
---|
129 | self.confdir, os.path.normpath(data[0]))) |
---|
130 | elif directive == 'options': |
---|
131 | self.options = eval(' '.join(data)) |
---|
132 | for i,v in enumerate(self.options): |
---|
133 | if isinstance(v, basestring): |
---|
134 | self.options[i] = (v,None) |
---|
135 | elif directive == 'attributes': |
---|
136 | self.attributes = eval(' '.join(data)) |
---|
137 | elif directive == 'backends': |
---|
138 | self.backends = eval(' '.join(data)) |
---|
139 | elif directive == 'name': |
---|
140 | self.name = data[0].strip() |
---|
141 | else: |
---|
142 | raise (ValueError, 'illegal directive: %s' % l[0]) |
---|
143 | if not self.title: |
---|
144 | self.title = self.source |
---|
145 | if not self.name: |
---|
146 | self.name = os.path.basename(os.path.splitext(self.source)[0]) |
---|
147 | |
---|
148 | def is_missing(self, backend): |
---|
149 | """ |
---|
150 | Returns True if there is no output test data file for backend. |
---|
151 | """ |
---|
152 | return not os.path.isfile(self.backend_filename(backend)) |
---|
153 | |
---|
154 | def is_missing_or_outdated(self, backend): |
---|
155 | """ |
---|
156 | Returns True if the output test data file is missing or out of date. |
---|
157 | """ |
---|
158 | return self.is_missing(backend) or ( |
---|
159 | os.path.getmtime(self.source) |
---|
160 | > os.path.getmtime(self.backend_filename(backend))) |
---|
161 | |
---|
162 | def get_expected(self, backend): |
---|
163 | """ |
---|
164 | Return expected test data output for backend. |
---|
165 | """ |
---|
166 | f = open(self.backend_filename(backend)) |
---|
167 | try: |
---|
168 | result = f.readlines() |
---|
169 | # Strip line terminators. |
---|
170 | result = [ s.rstrip() for s in result ] |
---|
171 | finally: |
---|
172 | f.close() |
---|
173 | return result |
---|
174 | |
---|
175 | def generate_expected(self, backend): |
---|
176 | """ |
---|
177 | Generate and return test data output for backend. |
---|
178 | """ |
---|
179 | asciidoc = asciidocapi.AsciiDocAPI() |
---|
180 | asciidoc.options.values = self.options |
---|
181 | asciidoc.attributes = self.attributes |
---|
182 | infile = self.source |
---|
183 | outfile = StringIO.StringIO() |
---|
184 | asciidoc.execute(infile, outfile, backend) |
---|
185 | return outfile.getvalue().splitlines() |
---|
186 | |
---|
187 | def update_expected(self, backend): |
---|
188 | """ |
---|
189 | Generate and write backend data. |
---|
190 | """ |
---|
191 | lines = self.generate_expected(backend) |
---|
192 | if not os.path.isdir(self.datadir): |
---|
193 | print('CREATING: %s' % self.datadir) |
---|
194 | os.mkdir(self.datadir) |
---|
195 | f = open(self.backend_filename(backend),'w+') |
---|
196 | try: |
---|
197 | print('WRITING: %s' % f.name) |
---|
198 | f.writelines([ s + os.linesep for s in lines]) |
---|
199 | finally: |
---|
200 | f.close() |
---|
201 | |
---|
202 | def update(self, backend=None, force=False): |
---|
203 | """ |
---|
204 | Regenerate and update expected test data outputs. |
---|
205 | """ |
---|
206 | if backend is None: |
---|
207 | backends = self.backends |
---|
208 | else: |
---|
209 | backends = [backend] |
---|
210 | for backend in backends: |
---|
211 | if force or self.is_missing_or_outdated(backend): |
---|
212 | self.update_expected(backend) |
---|
213 | |
---|
214 | def run(self, backend=None): |
---|
215 | """ |
---|
216 | Execute test. |
---|
217 | Return True if test passes. |
---|
218 | """ |
---|
219 | if backend is None: |
---|
220 | backends = self.backends |
---|
221 | else: |
---|
222 | backends = [backend] |
---|
223 | result = True # Assume success. |
---|
224 | self.passed = self.failed = self.skipped = 0 |
---|
225 | print('%d: %s' % (self.number, self.title)) |
---|
226 | if self.source and os.path.isfile(self.source): |
---|
227 | print('SOURCE: asciidoc: %s' % self.source) |
---|
228 | for backend in backends: |
---|
229 | fromfile = self.backend_filename(backend) |
---|
230 | if not self.is_missing(backend): |
---|
231 | expected = self.get_expected(backend) |
---|
232 | strip_end(expected) |
---|
233 | got = self.generate_expected(backend) |
---|
234 | strip_end(got) |
---|
235 | lines = [] |
---|
236 | for line in difflib.unified_diff(got, expected, n=0): |
---|
237 | lines.append(line) |
---|
238 | if lines: |
---|
239 | result = False |
---|
240 | self.failed +=1 |
---|
241 | lines = lines[3:] |
---|
242 | print('FAILED: %s: %s' % (backend, fromfile)) |
---|
243 | message('+++ %s' % fromfile) |
---|
244 | message('--- got') |
---|
245 | for line in lines: |
---|
246 | message(line) |
---|
247 | message() |
---|
248 | else: |
---|
249 | self.passed += 1 |
---|
250 | print('PASSED: %s: %s' % (backend, fromfile)) |
---|
251 | else: |
---|
252 | self.skipped += 1 |
---|
253 | print('SKIPPED: %s: %s' % (backend, fromfile)) |
---|
254 | else: |
---|
255 | self.skipped += len(backends) |
---|
256 | if self.source: |
---|
257 | msg = 'MISSING: %s' % self.source |
---|
258 | else: |
---|
259 | msg = 'NO ASCIIDOC SOURCE FILE SPECIFIED' |
---|
260 | print(msg) |
---|
261 | print('') |
---|
262 | return result |
---|
263 | |
---|
264 | |
---|
265 | class AsciiDocTests(object): |
---|
266 | |
---|
267 | def __init__(self, conffile): |
---|
268 | """ |
---|
269 | Parse configuration file. |
---|
270 | """ |
---|
271 | self.conffile = os.path.normpath(conffile) |
---|
272 | # All file names are relative to configuration file directory. |
---|
273 | self.confdir = os.path.dirname(self.conffile) |
---|
274 | self.datadir = self.confdir # Default expected files directory. |
---|
275 | self.tests = [] # List of parsed AsciiDocTest objects. |
---|
276 | self.globals = {} |
---|
277 | f = open(self.conffile) |
---|
278 | try: |
---|
279 | lines = Lines(f.readlines()) |
---|
280 | finally: |
---|
281 | f.close() |
---|
282 | first = True |
---|
283 | while not lines.eol(): |
---|
284 | s = lines.read_until(r'^%+$') |
---|
285 | s = [ l for l in s if l] # Drop blank lines. |
---|
286 | # Must be at least one non-blank line in addition to delimiter. |
---|
287 | if len(s) > 1: |
---|
288 | # Optional globals precede all tests. |
---|
289 | if first and re.match(r'^%\s*globals$',s[0]): |
---|
290 | self.globals = eval(' '.join(normalize_data(s[1:]))) |
---|
291 | if 'datadir' in self.globals: |
---|
292 | self.datadir = os.path.join( |
---|
293 | self.confdir, |
---|
294 | os.path.normpath(self.globals['datadir'])) |
---|
295 | else: |
---|
296 | test = AsciiDocTest() |
---|
297 | test.parse(s[1:], self.confdir, self.datadir) |
---|
298 | self.tests.append(test) |
---|
299 | test.number = len(self.tests) |
---|
300 | first = False |
---|
301 | |
---|
302 | def run(self, number=None, backend=None): |
---|
303 | """ |
---|
304 | Run all tests. |
---|
305 | If number is specified run test number (1..). |
---|
306 | """ |
---|
307 | self.passed = self.failed = self.skipped = 0 |
---|
308 | for test in self.tests: |
---|
309 | if (not test.disabled or number) and (not number or number == test.number) and (not backend or backend in test.backends): |
---|
310 | test.run(backend) |
---|
311 | self.passed += test.passed |
---|
312 | self.failed += test.failed |
---|
313 | self.skipped += test.skipped |
---|
314 | if self.passed > 0: |
---|
315 | print('TOTAL PASSED: %s' % self.passed) |
---|
316 | if self.failed > 0: |
---|
317 | print('TOTAL FAILED: %s' % self.failed) |
---|
318 | if self.skipped > 0: |
---|
319 | print('TOTAL SKIPPED: %s' % self.skipped) |
---|
320 | |
---|
321 | def update(self, number=None, backend=None, force=False): |
---|
322 | """ |
---|
323 | Regenerate expected test data and update configuratio file. |
---|
324 | """ |
---|
325 | for test in self.tests: |
---|
326 | if (not test.disabled or number) and (not number or number == test.number): |
---|
327 | test.update(backend, force=force) |
---|
328 | |
---|
329 | def list(self): |
---|
330 | """ |
---|
331 | Lists tests to stdout. |
---|
332 | """ |
---|
333 | for test in self.tests: |
---|
334 | print '%d: %s%s' % (test.number, iif(test.disabled,'!'), test.title) |
---|
335 | |
---|
336 | |
---|
337 | class Lines(list): |
---|
338 | """ |
---|
339 | A list of strings. |
---|
340 | Adds eol() and read_until() to list type. |
---|
341 | """ |
---|
342 | |
---|
343 | def __init__(self, lines): |
---|
344 | super(Lines, self).__init__() |
---|
345 | self.extend([s.rstrip() for s in lines]) |
---|
346 | self.pos = 0 |
---|
347 | |
---|
348 | def eol(self): |
---|
349 | return self.pos >= len(self) |
---|
350 | |
---|
351 | def read_until(self, regexp): |
---|
352 | """ |
---|
353 | Return a list of lines from current position up until the next line |
---|
354 | matching regexp. |
---|
355 | Advance position to matching line. |
---|
356 | """ |
---|
357 | result = [] |
---|
358 | if not self.eol(): |
---|
359 | result.append(self[self.pos]) |
---|
360 | self.pos += 1 |
---|
361 | while not self.eol(): |
---|
362 | if re.match(regexp, self[self.pos]): |
---|
363 | break |
---|
364 | result.append(self[self.pos]) |
---|
365 | self.pos += 1 |
---|
366 | return result |
---|
367 | |
---|
368 | |
---|
369 | def usage(msg=None): |
---|
370 | if msg: |
---|
371 | message(msg + '\n') |
---|
372 | message(USAGE) |
---|
373 | |
---|
374 | |
---|
375 | if __name__ == '__main__': |
---|
376 | # Process command line options. |
---|
377 | import getopt |
---|
378 | try: |
---|
379 | opts,args = getopt.getopt(sys.argv[1:], 'f:', ['force']) |
---|
380 | except getopt.GetoptError: |
---|
381 | usage('illegal command options') |
---|
382 | sys.exit(1) |
---|
383 | if len(args) == 0: |
---|
384 | usage() |
---|
385 | sys.exit(1) |
---|
386 | conffile = os.path.join(os.path.dirname(sys.argv[0]), 'testasciidoc.conf') |
---|
387 | force = False |
---|
388 | for o,v in opts: |
---|
389 | if o == '--force': |
---|
390 | force = True |
---|
391 | if o in ('-f','--conf-file'): |
---|
392 | conffile = v |
---|
393 | if not os.path.isfile(conffile): |
---|
394 | message('missing CONF_FILE: %s' % conffile) |
---|
395 | sys.exit(1) |
---|
396 | tests = AsciiDocTests(conffile) |
---|
397 | cmd = args[0] |
---|
398 | number = None |
---|
399 | backend = None |
---|
400 | for arg in args[1:3]: |
---|
401 | try: |
---|
402 | number = int(arg) |
---|
403 | except ValueError: |
---|
404 | backend = arg |
---|
405 | if backend and backend not in BACKENDS: |
---|
406 | message('illegal BACKEND: %s' % backend) |
---|
407 | sys.exit(1) |
---|
408 | if number is not None and number not in range(1, len(tests.tests)+1): |
---|
409 | message('illegal test NUMBER: %d' % number) |
---|
410 | sys.exit(1) |
---|
411 | if cmd == 'run': |
---|
412 | tests.run(number, backend) |
---|
413 | if tests.failed: |
---|
414 | exit(1) |
---|
415 | elif cmd == 'update': |
---|
416 | tests.update(number, backend, force=force) |
---|
417 | elif cmd == 'list': |
---|
418 | tests.list() |
---|
419 | else: |
---|
420 | usage('illegal COMMAND: %s' % cmd) |
---|