| #!/usr/bin/env python |
| # Licensed to the Apache Software Foundation (ASF) under one |
| # or more contributor license agreements. See the NOTICE file |
| # distributed with this work for additional information |
| # regarding copyright ownership. The ASF licenses this file |
| # to you under the Apache License, Version 2.0 (the |
| # "License"); you may not use this file except in compliance |
| # with the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, |
| # software distributed under the License is distributed on an |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| # KIND, either express or implied. See the License for the |
| # specific language governing permissions and limitations |
| # under the License. |
| # Line too long - pylint: disable=C0301 |
| # Invalid name - pylint: disable=C0103 |
| |
| """ |
| parseutils.py |
| |
| Routines to parse "flexible" configuration files for tools like |
| gpaddmirrors, gprecoverseg, gpexpand, etc. |
| """ |
| |
| import sys |
| from gppylib.mainUtils import ExceptionNoStackTraceNeeded |
| from gppylib.gplog import get_default_logger, logging_is_verbose |
| |
| logger = get_default_logger() |
| |
| def caller(): |
| "Return name of calling function" |
| if logging_is_verbose(): |
| return sys._getframe(1).f_code.co_name + '()' |
| return '' |
| |
| |
| def canonicalize_address(addr): |
| """ |
| Encases addresses in [ ] per RFC 2732. Generally used to deal with ':' |
| characters which are also often used as delimiters. |
| |
| Returns the addr string if it doesn't contain any ':' characters. |
| |
| If addr contains ':' and also contains a '[' then the addr string is |
| simply returned under the assumption that it is already escaped as needed. |
| |
| Otherwise return a new string from addr by adding '[' prefix and ']' suffix. |
| |
| Examples |
| -------- |
| |
| >>> canonicalize_address('myhost') |
| 'myhost' |
| >>> canonicalize_address('127.0.0.1') |
| '127.0.0.1' |
| >>> canonicalize_address('::1') |
| '[::1]' |
| >>> canonicalize_address('[::1]') |
| '[::1]' |
| >>> canonicalize_address('2620:0:170:610::13') |
| '[2620:0:170:610::13]' |
| >>> canonicalize_address('[2620:0:170:610::13]') |
| '[2620:0:170:610::13]' |
| |
| @param addr: the address to possibly encase in [ ] |
| @returns: the addresss, encased in [] if necessary |
| """ |
| if ':' not in addr: return addr |
| if '[' in addr: return addr |
| return '[' + addr + ']' |
| |
| |
| # |
| # line parsing |
| # |
| |
| def consume_to(delimiter, rest): |
| """ |
| Consume characters from rest string until we encounter the delimiter. |
| Returns (None, after, None) where after are the characters after delimiter |
| or (None, rest, 'does not contain '+delimiter) when delimiter is not encountered. |
| |
| Examples |
| -------- |
| |
| >>> consume_to('=', 'abc=def:ghi') |
| (None, 'def:ghi', None) |
| |
| @param delimiter: the delimiter string |
| @param rest: the string to read such as 'abc:def:ghi' |
| @returns: (None, after, None) tuple such as (None, 'def:ghi', None) |
| """ |
| p = rest.find(delimiter) |
| if p < 0: |
| return None, rest, 'does not contain '+delimiter |
| return None, rest[p+1:], None |
| |
| |
| |
| def read_to(delimiter, rest): |
| """ |
| Read characters from rest string until we encounter the delimiter. |
| Separate the string into characters 'before' and 'after' the delimiter. |
| If no delimiter is found, assign entire string to 'before' and None to 'after'. |
| |
| Examples |
| -------- |
| |
| >>> read_to(':', 'abc:def:ghi') |
| ('abc', 'def:ghi', None) |
| >>> read_to(':', 'abc:def') |
| ('abc', 'def', None) |
| >>> read_to(':', 'abc') |
| ('abc', None, None) |
| >>> read_to(':', '') |
| ('', None, None) |
| |
| Note this returns a 3-tuple for compatibility with other routines |
| which use the third element as an error message |
| |
| @param delimiter: the delimiter string |
| @param rest: the string to read such as 'abc:def:ghi' |
| @returns: (before, after, None) tuple such as ('abc', 'def:ghi', None) |
| """ |
| p = rest.find(delimiter) |
| if p < 0: |
| return rest, None, None |
| return rest[0:p], rest[p+1:], None |
| |
| |
| def read_to_bracketed(delimiter, rest): |
| """ |
| Read characters from rest string which is expected to start with a '['. |
| If rest does not start with '[', return a tuple (None, rest, 'does not begin with ['). |
| |
| If rest string starts with a '[', then read until we find ']'. |
| If no ']' is found, return a tuple (None, rest, 'does not contain ending ]'). |
| |
| Otherwise separate the string into 'before' representing characters between |
| '[' and ']' and 'after' representing characters after the ']' and then check |
| that the first character found after the ']' is a :'. |
| |
| If there are no characters after the ']', return a tuple (before, None, None) |
| where before contains the characters between '[' and ']'. |
| |
| If there are characters after ']' other than the delimiter, return a tuple |
| (None, rest, 'characters not allowed after ending ]') |
| |
| Otherwise return a tuple (before, after, None) where before contains the |
| characters between '[' and ']' and after contains the characters after the ']:'. |
| |
| This function avoids raising Exceptions for these particular cases of |
| malformed input since they are easier to report in the calling function. |
| |
| Examples |
| -------- |
| |
| >>> read_to_bracketed(':', '[abc:def]') |
| ('abc:def', None, None) |
| >>> read_to_bracketed(':', '[abc]:def:ghi') |
| ('abc', 'def:ghi', None) |
| >>> read_to_bracketed(':', '[abc:def]:ghi:jkl') |
| ('abc:def', 'ghi:jkl', None) |
| >>> read_to_bracketed(':', 'abc:def:ghi:jkl') |
| (None, 'abc:def:ghi:jkl', 'does not begin with [') |
| >>> read_to_bracketed(':', '[abc:def:ghi:jkl') |
| (None, '[abc:def:ghi:jkl', 'does not contain ending ]') |
| >>> read_to_bracketed(':', '[abc]extra:def:ghi:jkl') |
| (None, '[abc]extra:def:ghi:jkl', 'characters not allowed after ending ]') |
| |
| @param delimiter: the delimiter string |
| @param rest: the string to read such as '[abc:def]:ghi' |
| @returns: (before, after, reason) tuple such as ('abc:def', 'ghi', None) |
| """ |
| if not rest.startswith('['): |
| return None, rest, 'does not begin with [' |
| p = rest.find(']') |
| if p < 0: |
| return None, rest, 'does not contain ending ]' |
| if len(rest[p+1:]) < 1: |
| return rest[1:p], None, None |
| if rest[p+1] != delimiter: |
| return None, rest, 'characters not allowed after ending ]' |
| return rest[1:p], rest[p+2:], None |
| |
| |
| |
| def read_to_possibly_bracketed(delimiter, rest): |
| """ |
| Behave as read_bracketed above when rest starts with a '[', |
| otherwise as read_to_colon. This is intended to support fields |
| which may contain an IPv6 address, an IPv4 address or a hostname. |
| |
| Examples |
| -------- |
| |
| >>> read_to_possibly_bracketed(':', 'abc:def:ghi') |
| ('abc', 'def:ghi', None) |
| >>> read_to_possibly_bracketed(':', '[abc]:def:ghi') |
| ('abc', 'def:ghi', None) |
| >>> read_to_possibly_bracketed(':', '[abc:def]:ghi') |
| ('abc:def', 'ghi', None) |
| >>> read_to_possibly_bracketed(':', '[]:ghi') |
| ('', 'ghi', None) |
| >>> read_to_possibly_bracketed(':', ':ghi') |
| ('', 'ghi', None) |
| >>> read_to_possibly_bracketed(':', '[ghi]') |
| ('ghi', None, None) |
| >>> read_to_possibly_bracketed(':', '[]') |
| ('', None, None) |
| >>> read_to_possibly_bracketed(':', '') |
| ('', None, None) |
| |
| @param delimiter: the delimiter string |
| @param rest: the string to read such as '[abc:def]:ghi' |
| @returns: (before, after, reason) tuple such as ('abc:def', 'ghi', None) |
| """ |
| if rest.startswith('['): |
| return read_to_bracketed(delimiter, rest) |
| return read_to(delimiter, rest) |
| |
| |
| class LineParser: |
| """ |
| Manage state to parse a single line, generally from a configuration |
| file with fields delimited by colons. |
| """ |
| |
| def __init__(self, caller, filename, lineno, line): |
| "Initialize" |
| (self.caller, self.filename, self.lineno, self.line, self.rest, self.error) = (caller, filename, lineno, line, line, None) |
| self.logger = logger |
| if logging_is_verbose(): |
| self.logger.debug("%s:%s" % (filename, lineno)) |
| |
| def ensure_more_to_process(self, name): |
| "Raise an exception if we've exhausted the input line" |
| if self.rest is None: |
| msg = "out of values (reading %s)" % name |
| raise ExceptionNoStackTraceNeeded("%s:%s:%s LINE >>%s\n%s" % (self.filename, self.lineno, self.caller, self.line, msg)) |
| |
| def read_delimited_field(self, delimiter, name="next field", reader=read_to): |
| """ |
| Attempts to read the next field in the line up to the specified delimiter |
| using the specified reading method, raising any error encountered as an |
| exception. Returns the read field when successful. |
| """ |
| self.ensure_more_to_process(name) |
| value, self.rest, error = reader(delimiter, self.rest) |
| if error is not None: |
| msg = "%s (reading %s) >>%s" % (error, name, self.rest) |
| raise ExceptionNoStackTraceNeeded("%s:%s:%s LINE >>%s\n%s" % (self.filename, self.lineno, self.caller, self.line, msg)) |
| |
| if logging_is_verbose(): |
| self.logger.debug(" name=%-30s delimiter='%s' value=%s" % (name, delimiter, value)) |
| return value |
| |
| def does_starts_with(self, expected): |
| "Returns true if line starts with expected value, or return false" |
| return self.line.startswith(expected) |
| |
| def ensure_starts_with(self, expected): |
| "Returns true if line starts with expected value, or raise an exception otherwise" |
| if not self.does_starts_with(expected): |
| msg = "does not start with %s" % expected |
| raise ExceptionNoStackTraceNeeded("%s:%s:%s LINE >>%s\n%s" % (self.filename, self.lineno, self.caller, self.line, msg)) |
| self.rest = self.rest[len(expected):] |
| |
| def handle_field(self, name, dst=None, delimiter=':', stripchars=None): |
| """ |
| Attempts to read the next field up to a given delimiter. |
| Names starting with '[' indicate that the field should use the bracketed parsing logic. |
| If dst is not none, also assigns the value to dst[name]. |
| If stripchars is not none, value is first stripped of leading and trailing stripchars. |
| """ |
| if name[0] == '[': |
| name = name.strip('[]') |
| value = self.read_delimited_field(delimiter, name, read_to_possibly_bracketed) |
| else: |
| value = self.read_delimited_field(delimiter, name) |
| if stripchars is not None: |
| value = value.strip(stripchars) |
| if dst is not None: |
| dst[name] = value |
| return value |
| |
| |
| |
| |
| |
| # |
| # file parsing |
| # |
| |
| def line_reader(f): |
| """ |
| Read the contents of the given input, generating the non-blank non-comment |
| lines found within as a series of tuples of the form (line number, line). |
| |
| >>> [l for l in line_reader(['', '# test', 'abc:def'])] |
| [(3, 'abc:def')] |
| |
| """ |
| for offset, line in enumerate(f): |
| line = line.strip() |
| if len(line) < 1 or line[0] == '#': |
| continue |
| yield offset+1, line |
| |
| |
| |
| ################ |
| # gpfilespace format |
| # |
| # First line in file is the filespace name, remaining lines are |
| # specify hostname, dbid, and a path: |
| # |
| # filespace:name |
| # hostname:dbid:path |
| # ... |
| ################ |
| |
| def parse_fspacename(filename, lineno, line): |
| """ |
| Parse the filespace: line which appears at the beginning of the gpfilespace configuration file. |
| |
| >>> parse_fspacename('file', 1, 'filespace:blah') |
| 'blah' |
| """ |
| p = LineParser(caller(), filename, lineno, line) |
| p.ensure_starts_with('filespace:') |
| fspacename = p.read_delimited_field(':') |
| if p.rest is not None: |
| msg = "unexpected characters after filespace name >>%s" % p.rest |
| raise ExceptionNoStackTraceNeeded("%s:%s:%s LINE >>%s\n%s" % (filename, lineno, caller(), line, msg)) |
| return fspacename |
| |
| def parse_dfs_url(filename, lineno, line): |
| """ |
| Parse the filespace: line which appears at the beginning of the gpfilespace configuration file. |
| |
| >>> parse_dfs_url('file', 1, 'dfs_url::localhost:9000/gpsql') |
| 'localhost:9000/gpsql' |
| """ |
| p = LineParser(caller(), filename, lineno, line) |
| p.ensure_starts_with('dfs_url::') |
| dfs_url = p.read_delimited_field('::') |
| if p.rest is not None: |
| msg = "unexpected characters after filespace name >>%s" % p.rest |
| raise ExceptionNoStackTraceNeeded("%s:%s:%s LINE >>%s\n%s" % (filename, lineno, caller(), line, msg)) |
| return dfs_url |
| |
| def parse_fspacesys(filename, lineno, line): |
| """ |
| Pasrse the filesystem name: the optional second line in the gpfilespace configuration file. |
| |
| >>> parse_fspacetype('file', 2, 'fsysname:local|filesystem_name') |
| local|filesystem_name |
| """ |
| p = LineParser(caller(), filename, lineno, line) |
| if not p.does_starts_with('fsysname:'): |
| return None |
| p.ensure_starts_with('fsysname:') |
| fsysname = p.read_delimited_field(':') |
| if p.rest is not None: |
| msg = "unexpected characters after filespace type >>%s" % p.rest |
| raise ExceptionNoStackTraceNeeded("%s:%s:%s LINE >>%s\n%s" % (filename, lineno, caller(), line, msg)) |
| return fsysname |
| |
| def parse_fspacereplica(filename, lineno, line): |
| """ |
| Pasrse the filespace replica: the optional third line in the gpfilespace configuration file. |
| |
| >>> parse_fspacereplica('file', 3, 'fsreplica:repnum') |
| repnum |
| """ |
| p = LineParser(caller(), filename, lineno, line) |
| if not p.does_starts_with('fsreplica:'): |
| return None |
| p.ensure_starts_with('fsreplica:') |
| fsreplica = p.read_delimited_field(':') |
| if p.rest is not None: |
| msg = "unexpected characters after filespace replica >>%s" % p.rest |
| raise ExceptionNoStackTraceNeeded("%s:%s:%s LINE >>%s\n%s" % (filename, lineno, caller(), line, msg)) |
| return fsreplica |
| |
| def parse_gpfilespace_line(filename, lineno, line): |
| """ |
| Parse a line of the gpfilespace configuration file other than the first. |
| |
| >>> parse_gpfilespace_line('file', 1, '[::1]:dbid:path') |
| ('::1', 'dbid', 'path') |
| >>> parse_gpfilespace_line('file', 1, 'host:dbid:path') |
| ('host', 'dbid', 'path') |
| """ |
| p = LineParser(caller(), filename, lineno, line) |
| host = p.handle_field('[host]') # [host] indicates possible IPv6 address |
| dbid = p.handle_field('dbid') |
| path = p.handle_field('[path]') # url contains the ':'. |
| if p.rest is not None: |
| msg = "unexpected characters after path name >>%s" % p.rest |
| raise ExceptionNoStackTraceNeeded("%s:%s:%s LINE >>%s\n%s" % (filename, lineno, caller(), line, msg)) |
| return host, dbid, path |
| |
| |
| ################ |
| # gpexpand segment file format: |
| # |
| # Form of file is hostname:address:port:dtadir:dbid:contentId:role[:replicationPort] |
| ################ |
| |
| def parse_gpexpand_segment_line(filename, lineno, line): |
| """ |
| Parse a line of the gpexpand configuration file. |
| |
| >>> parse_gpexpand_segment_line('file', 1, "localhost:[::1]:40001:/Users/ctaylor/data/p2/gpseg1:4:1:p") |
| ('localhost', '::1', '40001', '/Users/ctaylor/data/p2/gpseg1', '4', '1', 'p', None) |
| >>> parse_gpexpand_segment_line('file', 1, "localhost:[::1]:40001:/Users/ctaylor/data/p2/gpseg1:4:1:p:41001") |
| ('localhost', '::1', '40001', '/Users/ctaylor/data/p2/gpseg1', '4', '1', 'p', '41001') |
| """ |
| p = LineParser(caller(), filename, lineno, line) |
| hostname = p.handle_field('[host]') # [host] indicates possible IPv6 address |
| address = p.handle_field('[address]') # [address] indicates possible IPv6 address |
| port = p.handle_field('port') |
| datadir = p.handle_field('datadir') |
| dbid = p.handle_field('dbid') |
| contentId = p.handle_field('contentId') |
| role = p.handle_field('role') |
| replicationPort = None |
| if p.rest is not None: |
| replicationPort = p.handle_field('replicationPort') |
| if p.rest is not None: |
| msg = "unexpected characters after replicationPort >>%s" % p.rest |
| raise ExceptionNoStackTraceNeeded("%s:%s:%s LINE >>%s\n%s" % (filename, lineno, caller(), line, msg)) |
| return hostname, address, port, datadir, dbid, contentId, role, replicationPort |
| |
| |
| ################ |
| # gpaddmirrors format: |
| # |
| # filespaceOrder=[filespace1_fsname[:filespace2_fsname:...]] |
| # mirror[content]=content:address:port:mir_replication_port:pri_replication_port:fselocation[:fselocation:...] |
| # |
| ################ |
| |
| def parse_filespace_order(filename, lineno, line): |
| """ |
| Parse the filespaceOrder= line appearing at the beginning of the gpaddmirrors, |
| gpmovemirrors and gprecoverseg configuration files. |
| |
| >>> parse_filespace_order('file', 1, "filespaceOrder=fs1:fs2:fs3") |
| ['fs1', 'fs2', 'fs3'] |
| >>> parse_filespace_order('file', 1, "filespaceOrder=") |
| [] |
| """ |
| p = LineParser(caller(), filename, lineno, line) |
| p.ensure_starts_with('filespaceOrder=') |
| fslist = [] |
| while p.rest: |
| fslist.append( p.read_delimited_field(':', 'next filespace') ) |
| return fslist |
| |
| |
| def parse_gpaddmirrors_line(filename, lineno, line, fslist): |
| """ |
| Parse a line in the gpaddmirrors configuration file other than the first. |
| |
| >>> line = "mirror0=0:[::1]:40001:50001:60001:/Users/ctaylor/data/p2/gpseg1" |
| >>> fixed, flex = parse_gpaddmirrors_line('file', 1, line, []) |
| >>> fixed["address"], fixed["contentId"], fixed["dataDirectory"] |
| ('::1', '0', '/Users/ctaylor/data/p2/gpseg1') |
| |
| """ |
| fixed = {} |
| flexible = {} |
| p = LineParser(caller(), filename, lineno, line) |
| p.ensure_starts_with('mirror') |
| p.read_delimited_field('=', 'content id', consume_to) |
| # [address] indicates possible IPv6 address |
| for field in [ 'contentId', '[address]', 'port', 'replicationPort', 'primarySegmentReplicationPort', 'dataDirectory' ]: |
| p.handle_field(field, fixed) |
| for fsname in fslist: |
| p.handle_field('[' + fsname + ']', flexible) |
| return fixed, flexible |
| |
| |
| |
| ################ |
| # gpmovemirrors format: |
| # |
| # This is basically the same as the gprecoverseg format (since gpmovemirrors ultimately just |
| # passes the input file after validating it) but the field names are slightly different. |
| # |
| # filespaceOrder=[filespace1_fsname[:filespace2_fsname:...] |
| # old_address:port:datadir new_address:port:replication_port:datadir[:fselocation:...] |
| # ^ |
| # note space |
| ################ |
| |
| def parse_gpmovemirrors_line(filename, lineno, line, fslist): |
| """ |
| Parse a line in the gpmovemirrors configuration file other than the first. |
| |
| >>> line = "[::1]:40001:/Users/ctaylor/data/m2/gpseg1 [::2]:40101:50101:/Users/ctaylor/data/m2/gpseg1:/fs1" |
| >>> fixed, flex = parse_gpmovemirrors_line('file', 1, line, ['fs1']) |
| >>> fixed["oldAddress"], fixed["newAddress"] |
| ('::1', '::2') |
| >>> flex |
| {'fs1': '/fs1'} |
| |
| """ |
| groups = len(line.split()) |
| if groups != 2: |
| msg = "need two groups of fields delimited by a space for old and new mirror, not %d" % groups |
| raise ExceptionNoStackTraceNeeded("%s:%s:%s LINE >>%s\n%s" % (filename, lineno, caller(), line, msg)) |
| fixed = {} |
| flexible = {} |
| p = LineParser(caller(), filename, lineno, line) |
| p.handle_field('[oldAddress]', fixed) # [oldAddress] indicates possible IPv6 address |
| p.handle_field('oldPort', fixed) |
| p.handle_field('oldDataDirectory', fixed, delimiter=' ', stripchars=' \t') # MPP-15675 note stripchars here and next line |
| p.handle_field('[newAddress]', fixed, stripchars=' \t') # [newAddress] indicates possible IPv6 address |
| p.handle_field('newPort', fixed) |
| p.handle_field('newReplicationPort', fixed) |
| p.handle_field('newDataDirectory', fixed) |
| for fsname in fslist: |
| p.handle_field(fsname, flexible) |
| if p.rest is not None: |
| msg = "unexpected characters after mirror fields >>%s" % p.rest |
| raise ExceptionNoStackTraceNeeded("%s:%s:%s LINE >>%s\n%s" % (filename, lineno, caller(), line, msg)) |
| return fixed, flexible |
| |
| |
| ################ |
| # gprecoverseg format: |
| # |
| # filespaceOrder=[filespace1_fsname[:filespace2_fsname:...]] |
| # failed_host_address:port:datadir [recovery_host_address:port:replication_port:datadir[:fselocation:...]] |
| # ^ |
| # note space |
| # |
| # filespace locations are only present at the end of the other fields when there |
| # are two groups of fields separated by a space. If there is only one group of |
| # fields then we assume the entire line is only three fields as below with no |
| # filespace locations: |
| # |
| # failed_host_address:port:datadir |
| ################ |
| |
| def parse_gprecoverseg_line(filename, lineno, line, fslist): |
| """ |
| Parse a line in the gprecoverseg configuration file other than the first. |
| |
| >>> line = "[::1]:40001:/Users/ctaylor/data/m2/gpseg1" |
| >>> fixed, flex = parse_gprecoverseg_line('file', 1, line, []) |
| >>> fixed["failedAddress"], fixed["failedPort"], fixed["failedDataDirectory"] |
| ('::1', '40001', '/Users/ctaylor/data/m2/gpseg1') |
| |
| >>> line = "[::1]:40001:/Users/ctaylor/data/m2/gpseg1 [::2]:40101:50101:/Users/ctaylor/data/m2/gpseg1:/fs1" |
| >>> fixed, flex = parse_gprecoverseg_line('file', 1, line, ['fs1']) |
| >>> fixed["newAddress"], fixed["newPort"], fixed["newReplicationPort"], fixed["newDataDirectory"] |
| ('::2', '40101', '50101', '/Users/ctaylor/data/m2/gpseg1') |
| >>> flex |
| {'fs1': '/fs1'} |
| """ |
| |
| groups = len(line.split()) |
| if groups not in [1, 2]: |
| msg = "only one or two groups of fields delimited by a space, not %d" % groups |
| raise ExceptionNoStackTraceNeeded("%s:%s:%s LINE >>%s\n%s" % (filename, lineno, caller(), line, msg)) |
| fixed = {} |
| flexible = {} |
| p = LineParser(caller(), filename, lineno, line) |
| p.handle_field('[failedAddress]', fixed) # [failedAddress] indicates possible IPv6 address |
| p.handle_field('failedPort', fixed) |
| if groups == 1: |
| p.handle_field('failedDataDirectory', fixed) |
| else: |
| p.handle_field('failedDataDirectory', fixed, delimiter=' ', stripchars=' \t') # MPP-15675 note stripchars here and next line |
| p.handle_field('[newAddress]', fixed, stripchars=' \t') # [newAddress] indicates possible IPv6 address |
| p.handle_field('newPort', fixed) |
| p.handle_field('newReplicationPort', fixed) |
| p.handle_field('newDataDirectory', fixed) |
| for fsname in fslist: |
| p.handle_field('[' + fsname + ']', flexible) |
| return fixed, flexible |
| |
| |
| |
| if __name__ == '__main__': |
| import doctest |
| doctest.testmod() |
| |
| |