Aluode/PerceptionLabPortable
0
1"""text_file2 3provides the TextFile class, which gives an interface to text files4that (optionally) takes care of stripping comments, ignoring blank5lines, and joining lines with backslashes."""6 7import sys8 9 10class TextFile:11 """Provides a file-like object that takes care of all the things you12 commonly want to do when processing a text file that has some13 line-by-line syntax: strip comments (as long as "#" is your14 comment character), skip blank lines, join adjacent lines by15 escaping the newline (ie. backslash at end of line), strip16 leading and/or trailing whitespace. All of these are optional17 and independently controllable.18 19 Provides a 'warn()' method so you can generate warning messages that20 report physical line number, even if the logical line in question21 spans multiple physical lines. Also provides 'unreadline()' for22 implementing line-at-a-time lookahead.23 24 Constructor is called as:25 26 TextFile (filename=None, file=None, **options)27 28 It bombs (RuntimeError) if both 'filename' and 'file' are None;29 'filename' should be a string, and 'file' a file object (or30 something that provides 'readline()' and 'close()' methods). It is31 recommended that you supply at least 'filename', so that TextFile32 can include it in warning messages. If 'file' is not supplied,33 TextFile creates its own using 'io.open()'.34 35 The options are all boolean, and affect the value returned by36 'readline()':37 strip_comments [default: true]38 strip from "#" to end-of-line, as well as any whitespace39 leading up to the "#" -- unless it is escaped by a backslash40 lstrip_ws [default: false]41 strip leading whitespace from each line before returning it42 rstrip_ws [default: true]43 strip trailing whitespace (including line terminator!) from44 each line before returning it45 skip_blanks [default: true}46 skip lines that are empty *after* stripping comments and47 whitespace. (If both lstrip_ws and rstrip_ws are false,48 then some lines may consist of solely whitespace: these will49 *not* be skipped, even if 'skip_blanks' is true.)50 join_lines [default: false]51 if a backslash is the last non-newline character on a line52 after stripping comments and whitespace, join the following line53 to it to form one "logical line"; if N consecutive lines end54 with a backslash, then N+1 physical lines will be joined to55 form one logical line.56 collapse_join [default: false]57 strip leading whitespace from lines that are joined to their58 predecessor; only matters if (join_lines and not lstrip_ws)59 errors [default: 'strict']60 error handler used to decode the file content61 62 Note that since 'rstrip_ws' can strip the trailing newline, the63 semantics of 'readline()' must differ from those of the builtin file64 object's 'readline()' method! In particular, 'readline()' returns65 None for end-of-file: an empty string might just be a blank line (or66 an all-whitespace line), if 'rstrip_ws' is true but 'skip_blanks' is67 not."""68 69 default_options = {70 'strip_comments': 1,71 'skip_blanks': 1,72 'lstrip_ws': 0,73 'rstrip_ws': 1,74 'join_lines': 0,75 'collapse_join': 0,76 'errors': 'strict',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 affect83 the values returned by 'readline()'."""84 if filename is None and file is None:85 raise RuntimeError(86 "you must supply either or both of 'filename' and 'file'"87 )88 89 # set values for all options -- either from client option hash90 # or fallback to default_options91 for opt in self.default_options.keys():92 if opt in options:93 setattr(self, opt, options[opt])94 else:95 setattr(self, opt, self.default_options[opt])96 97 # sanity check client option hash98 for opt in options.keys():99 if opt not in self.default_options:100 raise KeyError(f"invalid TextFile option '{opt}'")101 102 if file is None:103 self.open(filename)104 else:105 self.filename = filename106 self.file = file107 self.current_line = 0 # assuming that file is at BOF!108 109 # 'linebuf' is a stack of lines that will be emptied before we110 # actually read from the file; it's only populated by an111 # 'unreadline()' operation112 self.linebuf = []113 114 def open(self, filename):115 """Open a new file named 'filename'. This overrides both the116 'filename' and 'file' arguments to the constructor."""117 self.filename = filename118 self.file = open(self.filename, errors=self.errors, encoding='utf-8')119 self.current_line = 0120 121 def close(self):122 """Close the current file and forget everything we know about it123 (filename, current line number)."""124 file = self.file125 self.file = None126 self.filename = None127 self.current_line = None128 file.close()129 130 def gen_error(self, msg, line=None):131 outmsg = []132 if line is None:133 line = self.current_line134 outmsg.append(self.filename + ", ")135 if isinstance(line, (list, tuple)):136 outmsg.append("lines {}-{}: ".format(*line))137 else:138 outmsg.append(f"line {int(line)}: ")139 outmsg.append(str(msg))140 return "".join(outmsg)141 142 def error(self, msg, line=None):143 raise ValueError("error: " + self.gen_error(msg, line))144 145 def warn(self, msg, line=None):146 """Print (to stderr) a warning message tied to the current logical147 line in the current file. If the current logical line in the148 file spans multiple physical lines, the warning refers to the149 whole range, eg. "lines 3-5". If 'line' supplied, it overrides150 the current line number; it may be a list or tuple to indicate a151 range of physical lines, or an integer for a single physical152 line."""153 sys.stderr.write("warning: " + self.gen_error(msg, line) + "\n")154 155 def readline(self): # noqa: C901156 """Read and return a single logical line from the current file (or157 from an internal buffer if lines have previously been "unread"158 with 'unreadline()'). If the 'join_lines' option is true, this159 may involve reading multiple physical lines concatenated into a160 single string. Updates the current line number, so calling161 'warn()' after 'readline()' emits a warning about the physical162 line(s) just read. Returns None on end-of-file, since the empty163 string can occur if 'rstrip_ws' is true but 'strip_blanks' is164 not."""165 # If any "unread" lines waiting in 'linebuf', return the top166 # one. (We don't actually buffer read-ahead data -- lines only167 # get put in 'linebuf' if the client explicitly does an168 # 'unreadline()'.169 if self.linebuf:170 line = self.linebuf[-1]171 del self.linebuf[-1]172 return line173 174 buildup_line = ''175 176 while True:177 # read the line, make it None if EOF178 line = self.file.readline()179 if line == '':180 line = None181 182 if self.strip_comments and line:183 # Look for the first "#" in the line. If none, never184 # mind. If we find one and it's the first character, or185 # is not preceded by "\", then it starts a comment --186 # strip the comment, strip whitespace before it, and187 # carry on. Otherwise, it's just an escaped "#", so188 # unescape it (and any other escaped "#"'s that might be189 # lurking in there) and otherwise leave the line alone.190 191 pos = line.find("#")192 if pos == -1: # no "#" -- no comments193 pass194 195 # It's definitely a comment -- either "#" is the first196 # character, or it's elsewhere and unescaped.197 elif pos == 0 or line[pos - 1] != "\\":198 # Have to preserve the trailing newline, because it's199 # the job of a later step (rstrip_ws) to remove it --200 # and if rstrip_ws is false, we'd better preserve it!201 # (NB. this means that if the final line is all comment202 # and has no trailing newline, we will think that it's203 # EOF; I think that's OK.)204 eol = (line[-1] == '\n') and '\n' or ''205 line = line[0:pos] + eol206 207 # If all that's left is whitespace, then skip line208 # *now*, before we try to join it to 'buildup_line' --209 # that way constructs like210 # hello \\211 # # comment that should be ignored212 # there213 # result in "hello there".214 if line.strip() == "":215 continue216 else: # it's an escaped "#"217 line = line.replace("\\#", "#")218 219 # did previous line end with a backslash? then accumulate220 if self.join_lines and buildup_line:221 # oops: end of file222 if line is None:223 self.warn("continuation line immediately precedes end-of-file")224 return buildup_line225 226 if self.collapse_join:227 line = line.lstrip()228 line = buildup_line + line229 230 # careful: pay attention to line number when incrementing it231 if isinstance(self.current_line, list):232 self.current_line[1] = self.current_line[1] + 1233 else:234 self.current_line = [self.current_line, self.current_line + 1]235 # just an ordinary line, read it as usual236 else:237 if line is None: # eof238 return None239 240 # still have to be careful about incrementing the line number!241 if isinstance(self.current_line, list):242 self.current_line = self.current_line[1] + 1243 else:244 self.current_line = self.current_line + 1245 246 # strip whitespace however the client wants (leading and247 # trailing, or one or the other, or neither)248 if self.lstrip_ws and self.rstrip_ws:249 line = line.strip()250 elif self.lstrip_ws:251 line = line.lstrip()252 elif self.rstrip_ws:253 line = line.rstrip()254 255 # blank line (whether we rstrip'ed or not)? skip to next line256 # if appropriate257 if line in ('', '\n') and self.skip_blanks:258 continue259 260 if self.join_lines:261 if line[-1] == '\\':262 buildup_line = line[:-1]263 continue264 265 if line[-2:] == '\\\n':266 buildup_line = line[0:-2] + '\n'267 continue268 269 # well, I guess there's some actual content there: return it270 return line271 272 def readlines(self):273 """Read and return the list of all logical lines remaining in the274 current file."""275 lines = []276 while True:277 line = self.readline()278 if line is None:279 return lines280 lines.append(line)281 282 def unreadline(self, line):283 """Push 'line' (a string) onto an internal buffer that will be284 checked by future 'readline()' calls. Handy for implementing285 a parser with line-at-a-time lookahead."""286 self.linebuf.append(line)287 