]> git.saurik.com Git - wxWidgets.git/blob - wxPython/distutils/text_file.py
added tech note about writing unit tests
[wxWidgets.git] / wxPython / distutils / text_file.py
1 """text_file
2
3 provides the TextFile class, which gives an interface to text files
4 that (optionally) takes care of stripping comments, ignoring blank
5 lines, and joining lines with backslashes."""
6
7 __revision__ = "$Id$"
8
9 from types import *
10 import sys, os, string
11
12
13 class TextFile:
14
15 """Provides a file-like object that takes care of all the things you
16 commonly want to do when processing a text file that has some
17 line-by-line syntax: strip comments (as long as "#" is your
18 comment character), skip blank lines, join adjacent lines by
19 escaping the newline (ie. backslash at end of line), strip
20 leading and/or trailing whitespace. All of these are optional
21 and independently controllable.
22
23 Provides a 'warn()' method so you can generate warning messages that
24 report physical line number, even if the logical line in question
25 spans multiple physical lines. Also provides 'unreadline()' for
26 implementing line-at-a-time lookahead.
27
28 Constructor is called as:
29
30 TextFile (filename=None, file=None, **options)
31
32 It bombs (RuntimeError) if both 'filename' and 'file' are None;
33 'filename' should be a string, and 'file' a file object (or
34 something that provides 'readline()' and 'close()' methods). It is
35 recommended that you supply at least 'filename', so that TextFile
36 can include it in warning messages. If 'file' is not supplied,
37 TextFile creates its own using the 'open()' builtin.
38
39 The options are all boolean, and affect the value returned by
40 'readline()':
41 strip_comments [default: true]
42 strip from "#" to end-of-line, as well as any whitespace
43 leading up to the "#" -- unless it is escaped by a backslash
44 lstrip_ws [default: false]
45 strip leading whitespace from each line before returning it
46 rstrip_ws [default: true]
47 strip trailing whitespace (including line terminator!) from
48 each line before returning it
49 skip_blanks [default: true}
50 skip lines that are empty *after* stripping comments and
51 whitespace. (If both lstrip_ws and rstrip_ws are false,
52 then some lines may consist of solely whitespace: these will
53 *not* be skipped, even if 'skip_blanks' is true.)
54 join_lines [default: false]
55 if a backslash is the last non-newline character on a line
56 after stripping comments and whitespace, join the following line
57 to it to form one "logical line"; if N consecutive lines end
58 with a backslash, then N+1 physical lines will be joined to
59 form one logical line.
60 collapse_join [default: false]
61 strip leading whitespace from lines that are joined to their
62 predecessor; only matters if (join_lines and not lstrip_ws)
63
64 Note that since 'rstrip_ws' can strip the trailing newline, the
65 semantics of 'readline()' must differ from those of the builtin file
66 object's 'readline()' method! In particular, 'readline()' returns
67 None for end-of-file: an empty string might just be a blank line (or
68 an all-whitespace line), if 'rstrip_ws' is true but 'skip_blanks' is
69 not."""
70
71 default_options = { 'strip_comments': 1,
72 'skip_blanks': 1,
73 'lstrip_ws': 0,
74 'rstrip_ws': 1,
75 'join_lines': 0,
76 'collapse_join': 0,
77 }
78
79 def __init__ (self, filename=None, file=None, **options):
80 """Construct a new TextFile object. At least one of 'filename'
81 (a string) and 'file' (a file-like object) must be supplied.
82 They keyword argument options are described above and affect
83 the values returned by 'readline()'."""
84
85 if filename is None and file is None:
86 raise RuntimeError, \
87 "you must supply either or both of 'filename' and 'file'"
88
89 # set values for all options -- either from client option hash
90 # or fallback to default_options
91 for opt in self.default_options.keys():
92 if options.has_key (opt):
93 setattr (self, opt, options[opt])
94
95 else:
96 setattr (self, opt, self.default_options[opt])
97
98 # sanity check client option hash
99 for opt in options.keys():
100 if not self.default_options.has_key (opt):
101 raise KeyError, "invalid TextFile option '%s'" % opt
102
103 if file is None:
104 self.open (filename)
105 else:
106 self.filename = filename
107 self.file = file
108 self.current_line = 0 # assuming that file is at BOF!
109
110 # 'linebuf' is a stack of lines that will be emptied before we
111 # actually read from the file; it's only populated by an
112 # 'unreadline()' operation
113 self.linebuf = []
114
115
116 def open (self, filename):
117 """Open a new file named 'filename'. This overrides both the
118 'filename' and 'file' arguments to the constructor."""
119
120 self.filename = filename
121 self.file = open (self.filename, 'r')
122 self.current_line = 0
123
124
125 def close (self):
126 """Close the current file and forget everything we know about it
127 (filename, current line number)."""
128
129 self.file.close ()
130 self.file = None
131 self.filename = None
132 self.current_line = None
133
134
135 def gen_error (self, msg, line=None):
136 outmsg = []
137 if line is None:
138 line = self.current_line
139 outmsg.append(self.filename + ", ")
140 if type (line) in (ListType, TupleType):
141 outmsg.append("lines %d-%d: " % tuple (line))
142 else:
143 outmsg.append("line %d: " % line)
144 outmsg.append(str(msg))
145 return string.join(outmsg, "")
146
147
148 def error (self, msg, line=None):
149 raise ValueError, "error: " + self.gen_error(msg, line)
150
151 def warn (self, msg, line=None):
152 """Print (to stderr) a warning message tied to the current logical
153 line in the current file. If the current logical line in the
154 file spans multiple physical lines, the warning refers to the
155 whole range, eg. "lines 3-5". If 'line' supplied, it overrides
156 the current line number; it may be a list or tuple to indicate a
157 range of physical lines, or an integer for a single physical
158 line."""
159 sys.stderr.write("warning: " + self.gen_error(msg, line) + "\n")
160
161
162 def readline (self):
163 """Read and return a single logical line from the current file (or
164 from an internal buffer if lines have previously been "unread"
165 with 'unreadline()'). If the 'join_lines' option is true, this
166 may involve reading multiple physical lines concatenated into a
167 single string. Updates the current line number, so calling
168 'warn()' after 'readline()' emits a warning about the physical
169 line(s) just read. Returns None on end-of-file, since the empty
170 string can occur if 'rstrip_ws' is true but 'strip_blanks' is
171 not."""
172
173 # If any "unread" lines waiting in 'linebuf', return the top
174 # one. (We don't actually buffer read-ahead data -- lines only
175 # get put in 'linebuf' if the client explicitly does an
176 # 'unreadline()'.
177 if self.linebuf:
178 line = self.linebuf[-1]
179 del self.linebuf[-1]
180 return line
181
182 buildup_line = ''
183
184 while 1:
185 # read the line, make it None if EOF
186 line = self.file.readline()
187 if line == '': line = None
188
189 if self.strip_comments and line:
190
191 # Look for the first "#" in the line. If none, never
192 # mind. If we find one and it's the first character, or
193 # is not preceded by "\", then it starts a comment --
194 # strip the comment, strip whitespace before it, and
195 # carry on. Otherwise, it's just an escaped "#", so
196 # unescape it (and any other escaped "#"'s that might be
197 # lurking in there) and otherwise leave the line alone.
198
199 pos = string.find (line, "#")
200 if pos == -1: # no "#" -- no comments
201 pass
202
203 # It's definitely a comment -- either "#" is the first
204 # character, or it's elsewhere and unescaped.
205 elif pos == 0 or line[pos-1] != "\\":
206 # Have to preserve the trailing newline, because it's
207 # the job of a later step (rstrip_ws) to remove it --
208 # and if rstrip_ws is false, we'd better preserve it!
209 # (NB. this means that if the final line is all comment
210 # and has no trailing newline, we will think that it's
211 # EOF; I think that's OK.)
212 eol = (line[-1] == '\n') and '\n' or ''
213 line = line[0:pos] + eol
214
215 # If all that's left is whitespace, then skip line
216 # *now*, before we try to join it to 'buildup_line' --
217 # that way constructs like
218 # hello \\
219 # # comment that should be ignored
220 # there
221 # result in "hello there".
222 if string.strip(line) == "":
223 continue
224
225 else: # it's an escaped "#"
226 line = string.replace (line, "\\#", "#")
227
228
229 # did previous line end with a backslash? then accumulate
230 if self.join_lines and buildup_line:
231 # oops: end of file
232 if line is None:
233 self.warn ("continuation line immediately precedes "
234 "end-of-file")
235 return buildup_line
236
237 if self.collapse_join:
238 line = string.lstrip (line)
239 line = buildup_line + line
240
241 # careful: pay attention to line number when incrementing it
242 if type (self.current_line) is ListType:
243 self.current_line[1] = self.current_line[1] + 1
244 else:
245 self.current_line = [self.current_line,
246 self.current_line+1]
247 # just an ordinary line, read it as usual
248 else:
249 if line is None: # eof
250 return None
251
252 # still have to be careful about incrementing the line number!
253 if type (self.current_line) is ListType:
254 self.current_line = self.current_line[1] + 1
255 else:
256 self.current_line = self.current_line + 1
257
258
259 # strip whitespace however the client wants (leading and
260 # trailing, or one or the other, or neither)
261 if self.lstrip_ws and self.rstrip_ws:
262 line = string.strip (line)
263 elif self.lstrip_ws:
264 line = string.lstrip (line)
265 elif self.rstrip_ws:
266 line = string.rstrip (line)
267
268 # blank line (whether we rstrip'ed or not)? skip to next line
269 # if appropriate
270 if (line == '' or line == '\n') and self.skip_blanks:
271 continue
272
273 if self.join_lines:
274 if line[-1] == '\\':
275 buildup_line = line[:-1]
276 continue
277
278 if line[-2:] == '\\\n':
279 buildup_line = line[0:-2] + '\n'
280 continue
281
282 # well, I guess there's some actual content there: return it
283 return line
284
285 # readline ()
286
287
288 def readlines (self):
289 """Read and return the list of all logical lines remaining in the
290 current file."""
291
292 lines = []
293 while 1:
294 line = self.readline()
295 if line is None:
296 return lines
297 lines.append (line)
298
299
300 def unreadline (self, line):
301 """Push 'line' (a string) onto an internal buffer that will be
302 checked by future 'readline()' calls. Handy for implementing
303 a parser with line-at-a-time lookahead."""
304
305 self.linebuf.append (line)
306
307
308 if __name__ == "__main__":
309 test_data = """# test file
310
311 line 3 \\
312 # intervening comment
313 continues on next line
314 """
315 # result 1: no fancy options
316 result1 = map (lambda x: x + "\n", string.split (test_data, "\n")[0:-1])
317
318 # result 2: just strip comments
319 result2 = ["\n",
320 "line 3 \\\n",
321 " continues on next line\n"]
322
323 # result 3: just strip blank lines
324 result3 = ["# test file\n",
325 "line 3 \\\n",
326 "# intervening comment\n",
327 " continues on next line\n"]
328
329 # result 4: default, strip comments, blank lines, and trailing whitespace
330 result4 = ["line 3 \\",
331 " continues on next line"]
332
333 # result 5: strip comments and blanks, plus join lines (but don't
334 # "collapse" joined lines
335 result5 = ["line 3 continues on next line"]
336
337 # result 6: strip comments and blanks, plus join lines (and
338 # "collapse" joined lines
339 result6 = ["line 3 continues on next line"]
340
341 def test_input (count, description, file, expected_result):
342 result = file.readlines ()
343 # result = string.join (result, '')
344 if result == expected_result:
345 print "ok %d (%s)" % (count, description)
346 else:
347 print "not ok %d (%s):" % (count, description)
348 print "** expected:"
349 print expected_result
350 print "** received:"
351 print result
352
353
354 filename = "test.txt"
355 out_file = open (filename, "w")
356 out_file.write (test_data)
357 out_file.close ()
358
359 in_file = TextFile (filename, strip_comments=0, skip_blanks=0,
360 lstrip_ws=0, rstrip_ws=0)
361 test_input (1, "no processing", in_file, result1)
362
363 in_file = TextFile (filename, strip_comments=1, skip_blanks=0,
364 lstrip_ws=0, rstrip_ws=0)
365 test_input (2, "strip comments", in_file, result2)
366
367 in_file = TextFile (filename, strip_comments=0, skip_blanks=1,
368 lstrip_ws=0, rstrip_ws=0)
369 test_input (3, "strip blanks", in_file, result3)
370
371 in_file = TextFile (filename)
372 test_input (4, "default processing", in_file, result4)
373
374 in_file = TextFile (filename, strip_comments=1, skip_blanks=1,
375 join_lines=1, rstrip_ws=1)
376 test_input (5, "join lines without collapsing", in_file, result5)
377
378 in_file = TextFile (filename, strip_comments=1, skip_blanks=1,
379 join_lines=1, rstrip_ws=1, collapse_join=1)
380 test_input (6, "join lines with collapsing", in_file, result6)
381
382 os.remove (filename)