Commit | Line | Data |
---|---|---|
0235b0db MJ |
1 | # SPDX-License-Identifier: BSD-2-Clause |
2 | # | |
b85894a3 MJ |
3 | # Copyright (c) 2016, Matt Layman |
4 | ||
5 | from io import StringIO | |
6 | import re | |
7 | import sys | |
8 | ||
9 | from tap.directive import Directive | |
10 | from tap.i18n import _ | |
11 | from tap.line import Bail, Diagnostic, Plan, Result, Unknown, Version | |
12 | ||
13 | ||
14 | class Parser(object): | |
15 | """A parser for TAP files and lines.""" | |
16 | ||
17 | # ok and not ok share most of the same characteristics. | |
18 | result_base = r""" | |
19 | \s* # Optional whitespace. | |
20 | (?P<number>\d*) # Optional test number. | |
21 | \s* # Optional whitespace. | |
22 | (?P<description>[^#]*) # Optional description before #. | |
23 | \#? # Optional directive marker. | |
24 | \s* # Optional whitespace. | |
25 | (?P<directive>.*) # Optional directive text. | |
26 | """ | |
27 | ok = re.compile(r'^ok' + result_base, re.VERBOSE) | |
28 | not_ok = re.compile(r'^not\ ok' + result_base, re.VERBOSE) | |
768f9bcb MJ |
29 | plan = re.compile( |
30 | r""" | |
b85894a3 MJ |
31 | ^1..(?P<expected>\d+) # Match the plan details. |
32 | [^#]* # Consume any non-hash character to confirm only | |
33 | # directives appear with the plan details. | |
34 | \#? # Optional directive marker. | |
35 | \s* # Optional whitespace. | |
36 | (?P<directive>.*) # Optional directive text. | |
768f9bcb MJ |
37 | """, |
38 | re.VERBOSE, | |
39 | ) | |
b85894a3 | 40 | diagnostic = re.compile(r'^#') |
768f9bcb MJ |
41 | bail = re.compile( |
42 | r""" | |
b85894a3 MJ |
43 | ^Bail\ out! |
44 | \s* # Optional whitespace. | |
45 | (?P<reason>.*) # Optional reason. | |
768f9bcb MJ |
46 | """, |
47 | re.VERBOSE, | |
48 | ) | |
b85894a3 MJ |
49 | version = re.compile(r'^TAP version (?P<version>\d+)$') |
50 | ||
51 | TAP_MINIMUM_DECLARED_VERSION = 13 | |
52 | ||
53 | def parse_file(self, filename): | |
54 | """Parse a TAP file to an iterable of tap.line.Line objects. | |
55 | ||
56 | This is a generator method that will yield an object for each | |
57 | parsed line. The file given by `filename` is assumed to exist. | |
58 | """ | |
59 | return self.parse(open(filename, 'r')) | |
60 | ||
61 | def parse_stdin(self): | |
62 | """Parse a TAP stream from standard input. | |
63 | ||
64 | Note: this has the side effect of closing the standard input | |
65 | filehandle after parsing. | |
66 | """ | |
67 | return self.parse(sys.stdin) | |
68 | ||
69 | def parse_text(self, text): | |
70 | """Parse a string containing one or more lines of TAP output.""" | |
71 | return self.parse(StringIO(text)) | |
72 | ||
73 | def parse(self, fh): | |
74 | """Generate tap.line.Line objects, given a file-like object `fh`. | |
75 | ||
76 | `fh` may be any object that implements both the iterator and | |
77 | context management protocol (i.e. it can be used in both a | |
78 | "with" statement and a "for...in" statement.) | |
79 | ||
80 | Trailing whitespace and newline characters will be automatically | |
81 | stripped from the input lines. | |
82 | """ | |
83 | with fh: | |
84 | for line in fh: | |
85 | yield self.parse_line(line.rstrip()) | |
86 | ||
87 | def parse_line(self, text): | |
88 | """Parse a line into whatever TAP category it belongs.""" | |
89 | match = self.ok.match(text) | |
90 | if match: | |
91 | return self._parse_result(True, match) | |
92 | ||
93 | match = self.not_ok.match(text) | |
94 | if match: | |
95 | return self._parse_result(False, match) | |
96 | ||
97 | if self.diagnostic.match(text): | |
98 | return Diagnostic(text) | |
99 | ||
100 | match = self.plan.match(text) | |
101 | if match: | |
102 | return self._parse_plan(match) | |
103 | ||
104 | match = self.bail.match(text) | |
105 | if match: | |
106 | return Bail(match.group('reason')) | |
107 | ||
108 | match = self.version.match(text) | |
109 | if match: | |
110 | return self._parse_version(match) | |
111 | ||
112 | return Unknown() | |
113 | ||
114 | def _parse_plan(self, match): | |
115 | """Parse a matching plan line.""" | |
116 | expected_tests = int(match.group('expected')) | |
117 | directive = Directive(match.group('directive')) | |
118 | ||
119 | # Only SKIP directives are allowed in the plan. | |
120 | if directive.text and not directive.skip: | |
121 | return Unknown() | |
122 | ||
123 | return Plan(expected_tests, directive) | |
124 | ||
125 | def _parse_result(self, ok, match): | |
126 | """Parse a matching result line into a result instance.""" | |
127 | return Result( | |
768f9bcb MJ |
128 | ok, |
129 | match.group('number'), | |
130 | match.group('description').strip(), | |
131 | Directive(match.group('directive')), | |
132 | ) | |
b85894a3 MJ |
133 | |
134 | def _parse_version(self, match): | |
135 | version = int(match.group('version')) | |
136 | if version < self.TAP_MINIMUM_DECLARED_VERSION: | |
768f9bcb MJ |
137 | raise ValueError( |
138 | _('It is an error to explicitly specify ' 'any version lower than 13.') | |
139 | ) | |
b85894a3 | 140 | return Version(version) |