| r""" | |
| CSV parsing and writing. | |
| This module provides classes that assist in the reading and writing | |
| of Comma Separated Value (CSV) files, and implements the interface | |
| described by PEP 305. Although many CSV files are simple to parse, | |
| the format is not formally defined by a stable specification and | |
| is subtle enough that parsing lines of a CSV file with something | |
| like line.split(",") is bound to fail. The module supports three | |
| basic APIs: reading, writing, and registration of dialects. | |
| DIALECT REGISTRATION: | |
| Readers and writers support a dialect argument, which is a convenient | |
| handle on a group of settings. When the dialect argument is a string, | |
| it identifies one of the dialects previously registered with the module. | |
| If it is a class or instance, the attributes of the argument are used as | |
| the settings for the reader or writer: | |
| class excel: | |
| delimiter = ',' | |
| quotechar = '"' | |
| escapechar = None | |
| doublequote = True | |
| skipinitialspace = False | |
| lineterminator = '\r\n' | |
| quoting = QUOTE_MINIMAL | |
| SETTINGS: | |
| * quotechar - specifies a one-character string to use as the | |
| quoting character. It defaults to '"'. | |
| * delimiter - specifies a one-character string to use as the | |
| field separator. It defaults to ','. | |
| * skipinitialspace - specifies how to interpret spaces which | |
| immediately follow a delimiter. It defaults to False, which | |
| means that spaces immediately following a delimiter is part | |
| of the following field. | |
| * lineterminator - specifies the character sequence which should | |
| terminate rows. | |
| * quoting - controls when quotes should be generated by the writer. | |
| It can take on any of the following module constants: | |
| csv.QUOTE_MINIMAL means only when required, for example, when a | |
| field contains either the quotechar or the delimiter | |
| csv.QUOTE_ALL means that quotes are always placed around fields. | |
| csv.QUOTE_NONNUMERIC means that quotes are always placed around | |
| fields which do not parse as integers or floating-point | |
| numbers. | |
| csv.QUOTE_STRINGS means that quotes are always placed around | |
| fields which are strings. Note that the Python value None | |
| is not a string. | |
| csv.QUOTE_NOTNULL means that quotes are only placed around fields | |
| that are not the Python value None. | |
| csv.QUOTE_NONE means that quotes are never placed around fields. | |
| * escapechar - specifies a one-character string used to escape | |
| the delimiter when quoting is set to QUOTE_NONE. | |
| * doublequote - controls the handling of quotes inside fields. When | |
| True, two consecutive quotes are interpreted as one during read, | |
| and when writing, each quote character embedded in the data is | |
| written as two quotes | |
| """ | |
| import types | |
| from _csv import Error, writer, reader, register_dialect, \ | |
| unregister_dialect, get_dialect, list_dialects, \ | |
| field_size_limit, \ | |
| QUOTE_MINIMAL, QUOTE_ALL, QUOTE_NONNUMERIC, QUOTE_NONE, \ | |
| QUOTE_STRINGS, QUOTE_NOTNULL | |
| from _csv import Dialect as _Dialect | |
| from io import StringIO | |
| __all__ = ["QUOTE_MINIMAL", "QUOTE_ALL", "QUOTE_NONNUMERIC", "QUOTE_NONE", | |
| "QUOTE_STRINGS", "QUOTE_NOTNULL", | |
| "Error", "Dialect", "excel", "excel_tab", | |
| "field_size_limit", "reader", "writer", | |
| "register_dialect", "get_dialect", "list_dialects", "Sniffer", | |
| "unregister_dialect", "DictReader", "DictWriter", | |
| "unix_dialect"] | |
| _dialect_attributes = frozenset({ | |
| 'delimiter', 'quotechar', 'escapechar', 'doublequote', | |
| 'skipinitialspace', 'lineterminator', 'quoting', 'strict', | |
| }) | |
| class Dialect: | |
| """Describe a CSV dialect. | |
| This must be subclassed (see csv.excel). Valid attributes are: | |
| delimiter, quotechar, escapechar, doublequote, skipinitialspace, | |
| lineterminator, quoting. | |
| """ | |
| _name = "" | |
| _valid = False | |
| # placeholders | |
| delimiter = None | |
| quotechar = None | |
| escapechar = None | |
| doublequote = None | |
| skipinitialspace = None | |
| lineterminator = None | |
| quoting = None | |
| def __init__(self): | |
| if self.__class__ != Dialect: | |
| self._valid = True | |
| self._validate() | |
| def _validate(self): | |
| try: | |
| _Dialect(self) | |
| except TypeError as e: | |
| # Re-raise to get a traceback showing more user code. | |
| raise Error(str(e)) from None | |
| def __replace__(self, /, **changes): | |
| unexpected = changes.keys() - _dialect_attributes | |
| if unexpected: | |
| raise TypeError(f'__replace__() got an unexpected keyword ' | |
| f'argument {min(unexpected)!r}') | |
| new = object.__new__(self.__class__) | |
| new.__dict__.update(self.__dict__) | |
| new.__dict__.update(changes) | |
| new._validate() | |
| return new | |
| class excel(Dialect): | |
| """Describe the usual properties of Excel-generated CSV files.""" | |
| delimiter = ',' | |
| quotechar = '"' | |
| doublequote = True | |
| skipinitialspace = False | |
| lineterminator = '\r\n' | |
| quoting = QUOTE_MINIMAL | |
| register_dialect("excel", excel) | |
| class excel_tab(excel): | |
| """Describe the usual properties of Excel-generated TAB-delimited files.""" | |
| delimiter = '\t' | |
| register_dialect("excel-tab", excel_tab) | |
| class unix_dialect(Dialect): | |
| """Describe the usual properties of Unix-generated CSV files.""" | |
| delimiter = ',' | |
| quotechar = '"' | |
| doublequote = True | |
| skipinitialspace = False | |
| lineterminator = '\n' | |
| quoting = QUOTE_ALL | |
| register_dialect("unix", unix_dialect) | |
| class DictReader: | |
| def __init__(self, f, fieldnames=None, restkey=None, restval=None, | |
| dialect="excel", *args, **kwds): | |
| if fieldnames is not None and iter(fieldnames) is fieldnames: | |
| fieldnames = list(fieldnames) | |
| self._fieldnames = fieldnames # list of keys for the dict | |
| self.restkey = restkey # key to catch long rows | |
| self.restval = restval # default value for short rows | |
| self.reader = reader(f, dialect, *args, **kwds) | |
| self.dialect = dialect | |
| self.line_num = 0 | |
| def __iter__(self): | |
| return self | |
| @property | |
| def fieldnames(self): | |
| if self._fieldnames is None: | |
| try: | |
| self._fieldnames = next(self.reader) | |
| except StopIteration: | |
| pass | |
| self.line_num = self.reader.line_num | |
| return self._fieldnames | |
| @fieldnames.setter | |
| def fieldnames(self, value): | |
| self._fieldnames = value | |
| def __next__(self): | |
| if self.line_num == 0: | |
| # Used only for its side effect. | |
| self.fieldnames | |
| row = next(self.reader) | |
| self.line_num = self.reader.line_num | |
| # unlike the basic reader, we prefer not to return blanks, | |
| # because we will typically wind up with a dict full of None | |
| # values | |
| while row == []: | |
| row = next(self.reader) | |
| d = dict(zip(self.fieldnames, row)) | |
| lf = len(self.fieldnames) | |
| lr = len(row) | |
| if lf < lr: | |
| d[self.restkey] = row[lf:] | |
| elif lf > lr: | |
| for key in self.fieldnames[lr:]: | |
| d[key] = self.restval | |
| return d | |
| __class_getitem__ = classmethod(types.GenericAlias) | |
| class DictWriter: | |
| def __init__(self, f, fieldnames, restval="", extrasaction="raise", | |
| dialect="excel", *args, **kwds): | |
| if fieldnames is not None and iter(fieldnames) is fieldnames: | |
| fieldnames = list(fieldnames) | |
| self.fieldnames = fieldnames # list of keys for the dict | |
| self.restval = restval # for writing short dicts | |
| extrasaction = extrasaction.lower() | |
| if extrasaction not in ("raise", "ignore"): | |
| raise ValueError("extrasaction (%s) must be 'raise' or 'ignore'" | |
| % extrasaction) | |
| self.extrasaction = extrasaction | |
| self.writer = writer(f, dialect, *args, **kwds) | |
| def writeheader(self): | |
| header = dict(zip(self.fieldnames, self.fieldnames)) | |
| return self.writerow(header) | |
| def _dict_to_list(self, rowdict): | |
| if self.extrasaction == "raise": | |
| wrong_fields = rowdict.keys() - self.fieldnames | |
| if wrong_fields: | |
| raise ValueError("dict contains fields not in fieldnames: " | |
| + ", ".join([repr(x) for x in wrong_fields])) | |
| return (rowdict.get(key, self.restval) for key in self.fieldnames) | |
| def writerow(self, rowdict): | |
| return self.writer.writerow(self._dict_to_list(rowdict)) | |
| def writerows(self, rowdicts): | |
| return self.writer.writerows(map(self._dict_to_list, rowdicts)) | |
| __class_getitem__ = classmethod(types.GenericAlias) | |
| class Sniffer: | |
| ''' | |
| "Sniffs" the format of a CSV file (i.e. delimiter, quotechar) | |
| Returns a Dialect object. | |
| ''' | |
| # Characters which can be guessed as a delimiter if the delimiters | |
| # argument is not specified. | |
| _delimiter_candidates = [c for c in map(chr, range(128)) | |
| if not c.isalnum()] | |
| def __init__(self): | |
| # in case there is more than one possible delimiter | |
| self.preferred = [',', '\t', ';', ' ', ':'] | |
| def sniff(self, sample, delimiters=None): | |
| """ | |
| Analyze the sample and return a Dialect subclass reflecting the | |
| parameters found. If the optional delimiters parameter is | |
| given, it is interpreted as a string containing possible valid | |
| delimiter characters. Raises Error if the dialect cannot be | |
| determined. | |
| The dialect is guessed by parsing the sample with every | |
| plausible combination of delimiter, quotechar and escapechar, | |
| and choosing the combination which splits the sample into rows | |
| with the most consistent number of fields. | |
| A large sample is parsed incrementally: first only its | |
| beginning, then, after eliminating the combinations which are | |
| clearly worse than the leader, a several times larger part, | |
| and so on. | |
| If several combinations fit the sample equally well, the | |
| delimiters listed in the preferred attribute are preferred, in | |
| that order, no matter how many times each of them occurs. | |
| """ | |
| import re | |
| from collections import defaultdict | |
| if self._parses_as_single_column(sample): | |
| # There is no delimiter to find; any combination could | |
| # only find a bogus one inside the quoted fields. | |
| raise Error("Could not determine delimiter") | |
| chars = set(sample) | |
| if delimiters is None: | |
| delimiters = self._delimiter_candidates | |
| delimiters = [d for d in delimiters | |
| if d in chars and d not in '\r\n"\'\\'] | |
| # Combinations to try, numbered by preference for breaking | |
| # ties. The unquoted combinations are parsed from the start; | |
| # the rest stay dormant until the quote character occurs at | |
| # the start of a field (see _split_dormant). | |
| groups = defaultdict(list) | |
| order = 0 | |
| # Only '\\' is tried as an escape character: others are not | |
| # seen in the wild. | |
| for escapechar in ('', '\\') if '\\' in chars else ('',): | |
| for quotechar in '"', "'", '': | |
| if quotechar and quotechar not in chars: | |
| continue | |
| for delimiter in delimiters: | |
| groups[quotechar].append( | |
| (order, delimiter, quotechar, escapechar)) | |
| order += 1 | |
| active = groups.pop('', []) | |
| # Only non-empty groups were created; a plain dict cannot | |
| # grow one by accident. | |
| dormant = dict(groups) | |
| # The initial window should cover the minimal number of rows | |
| # required for elimination (see _eliminate_worse) at a typical | |
| # line length, so that the first round can already eliminate. | |
| window = 2000 | |
| # A line with its line break: '\r', '\n' or '\r\n' (the | |
| # reader treats other line boundary characters as ordinary | |
| # data, but does not support a bare '\r' inside a chunk). | |
| # The \z alternative produces one final empty match. | |
| line_re = re.compile(r'[^\r\n]*(?:\r\n|[\r\n]|\z)') | |
| parsed = [] | |
| lines = [] | |
| first_round = True | |
| while active or dormant: | |
| end = min(window, len(sample)) | |
| part = sample[:end] | |
| lines = line_re.findall(part) | |
| del lines[-1] | |
| cut = not part.endswith(('\r', '\n')) | |
| for quotechar in list(dormant): | |
| activated, still = self._split_dormant( | |
| part, quotechar, dormant[quotechar]) | |
| active += activated | |
| if still: | |
| dormant[quotechar] = still | |
| else: | |
| del dormant[quotechar] | |
| parsed = [(combo, self._try_dialect(lines, cut, *combo[1:])) | |
| for combo in active] | |
| if end == len(sample): | |
| break | |
| active = self._eliminate_worse(parsed, not first_round) | |
| first_round = False | |
| if len(active) <= 3: | |
| # Quoted data most often leaves three survivors: the | |
| # true dialect, its equally consistent unquoted shadow, | |
| # and one accident. Parsing the whole sample with them | |
| # is cheaper than another elimination round. | |
| window = len(sample) | |
| else: | |
| # Too small a factor would increase the total | |
| # re-parsing cost, too large -- the cost of the next | |
| # round if this one did not eliminate enough. | |
| window *= 4 | |
| best = None | |
| best_score = None | |
| for combo, rows in sorted(parsed): | |
| if rows is None: | |
| continue | |
| _, delimiter, quotechar, escapechar = combo | |
| nfields, share = self._modal_share(rows) | |
| if nfields < 2: | |
| # The delimiter does not delimit anything. | |
| continue | |
| try: | |
| preference = -self.preferred.index(delimiter) | |
| except ValueError: | |
| preference = -len(self.preferred) | |
| # A successful quoted parse is direct evidence; the preferred | |
| # delimiters list is only a nudge. | |
| score = (share, len(rows), bool(quotechar), preference) | |
| if best_score is None or score > best_score: | |
| best_score = score | |
| best = combo[1:] | |
| if best is None: | |
| raise Error("Could not determine delimiter") | |
| delimiter, quotechar, escapechar = best | |
| doublequote = self._detect_doublequote(lines, *best) | |
| skipinitialspace = self._detect_skipinitialspace(lines, *best, | |
| doublequote) | |
| class dialect(Dialect): | |
| _name = "sniffed" | |
| quoting = QUOTE_MINIMAL | |
| dialect.lineterminator = self._detect_lineterminator(lines) | |
| dialect.delimiter = delimiter | |
| # _csv.reader won't accept a quotechar of '' | |
| dialect.quotechar = quotechar or '"' | |
| dialect.escapechar = escapechar or None | |
| dialect.doublequote = doublequote | |
| dialect.skipinitialspace = skipinitialspace | |
| return dialect | |
| def _parses_as_single_column(self, sample): | |
| """ | |
| True if the whole sample parses as a single column of quoted | |
| fields (the last one may be cut off in the middle), so there | |
| is no delimiter to find. | |
| """ | |
| import re | |
| for q in '"', "'": | |
| if q in sample: | |
| row_re = (fr' *+{q}(?:[^{q}]|{q}{q})*+' | |
| fr'(?:{q} *+(?:[\r\n]++|\z)|\z)') | |
| if re.fullmatch(fr'(?:{row_re})++', sample): | |
| return True | |
| return False | |
| def _split_dormant(self, part, quotechar, combos): | |
| """ | |
| Split the dormant combinations into those ready for trial | |
| parsing and the rest. | |
| A combination is ready when its quote character occurs in | |
| *part* at the start of a field, i.e. at the start of a line or | |
| after its delimiter; until then parsing would not differ from | |
| the unquoted variant. Spaces before the quote are allowed even | |
| for the space delimiter: a false activation only costs a trial | |
| parse. | |
| """ | |
| import re | |
| remaining = {combo[1] for combo in combos} | |
| found = set() | |
| pos = 0 | |
| while remaining: | |
| # Include only the delimiters not found yet, so that the | |
| # search skips over the found ones; the compiled patterns | |
| # come from the re cache. | |
| cls = re.escape(''.join(sorted(remaining))) | |
| m = re.compile(fr'(?:^|([\r\n{cls}]))' | |
| fr' *{quotechar}').search(part, pos) | |
| if m is None: | |
| break | |
| pre = m[1] | |
| if pre is None or pre in '\r\n': | |
| # A quote at the start of a line starts a field for | |
| # every delimiter. | |
| found |= remaining | |
| break | |
| found.add(pre) | |
| remaining.discard(pre) | |
| pos = m.end() | |
| activated = [combo for combo in combos if combo[1] in found] | |
| still_dormant = [combo for combo in combos if combo[1] not in found] | |
| return activated, still_dormant | |
| def _make_reader(self, lines, delimiter, quotechar, escapechar, | |
| doublequote=True, skipinitialspace=None): | |
| """ | |
| Create a reader for trial parsing. quotechar '' means no | |
| quoting and escapechar '' means no escape character. | |
| """ | |
| if skipinitialspace is None: | |
| # Be lenient to spaces after a delimiter, unless the | |
| # delimiter is a space itself. | |
| skipinitialspace = delimiter != ' ' | |
| return reader(lines, delimiter=delimiter, | |
| quotechar=quotechar or '"', | |
| quoting=QUOTE_MINIMAL if quotechar else QUOTE_NONE, | |
| escapechar=escapechar or None, | |
| doublequote=doublequote, | |
| skipinitialspace=skipinitialspace, | |
| strict=True) | |
| def _try_dialect(self, lines, cut, delimiter, quotechar, escapechar): | |
| """ | |
| Parse the sample, pre-split into *lines*, and return the list | |
| of the number of fields in every parsed row, or None if not a | |
| single row was parsed. | |
| If the sample cannot be parsed to the end (for example it is | |
| cut off in the middle of a quoted field, or the combination | |
| does not fit the sample), the rows parsed so far are counted. | |
| The last row is not counted if *cut* is true: the sample can | |
| be cut off in the middle of it. | |
| """ | |
| rows = [] | |
| try: | |
| rows.extend(map(len, self._make_reader(lines, delimiter, | |
| quotechar, escapechar))) | |
| except Error: | |
| # The row which failed to parse is not counted. | |
| pass | |
| else: | |
| if cut and len(rows) > 1: | |
| rows.pop() | |
| if 0 in rows: | |
| # Blank lines produce empty rows. | |
| rows = [nfields for nfields in rows if nfields] | |
| return rows or None | |
| def _eliminate_worse(self, parsed, judge_hopeless): | |
| """ | |
| Return the combinations from *parsed* (a list of (combination, | |
| rows) pairs) without those which are clearly worse than the | |
| leader. Combinations with too few parsed rows (e.g. if the | |
| parsed part ends in the middle of a large quoted field) are | |
| not judged yet. | |
| If *judge_hopeless* is false, keep the combinations whose | |
| delimiter does not delimit anything. Unlike the comparison | |
| with the leader, which self-normalizes when the parsed part is | |
| not representative, this verdict is absolute and irreversible, | |
| so it is not trusted to the first part, which covers the least | |
| representative beginning of the sample (titles, headers, | |
| preamble). | |
| """ | |
| # Judging a combination by fewer rows is too noisy. | |
| min_rows = 16 | |
| hopeless = set() | |
| scores = {} | |
| for combo, rows in parsed: | |
| if rows is not None and len(rows) >= min_rows: | |
| nfields, share = self._modal_share(rows) | |
| if nfields < 2: | |
| if judge_hopeless: | |
| hopeless.add(combo) | |
| else: | |
| scores[combo] = share | |
| threshold = max(scores.values(), default=0.0) - 0.1 | |
| return [combo for combo, _ in parsed | |
| if combo not in hopeless | |
| and scores.get(combo, threshold) >= threshold] | |
| def _modal_share(self, rows): | |
| """ | |
| The most common number of fields in a row and its share of all | |
| rows. Prefer the smaller number of fields in the case of a | |
| tie: a candidate delimiter which delimits only half of the rows | |
| is not convincing. | |
| """ | |
| from collections import Counter | |
| counts = Counter(rows) | |
| nfields = max(counts, key=lambda n: (counts[n], -n)) | |
| return nfields, counts[nfields] / len(rows) | |
| def _detect_doublequote(self, lines, delimiter, quotechar, escapechar): | |
| """ | |
| True if a doubled quote character represents a single quote | |
| character in the sample: interpreting it so changes the result | |
| of parsing. | |
| """ | |
| if not quotechar or not any(quotechar * 2 in line | |
| for line in lines): | |
| return False | |
| readers = [self._make_reader( | |
| lines, delimiter, quotechar, escapechar, | |
| doublequote=doublequote) | |
| for doublequote in (False, True)] | |
| while True: | |
| rows = [] | |
| for rdr in readers: | |
| try: | |
| rows.append(next(rdr)) | |
| except (StopIteration, Error): | |
| # Ending cleanly and failing are equivalent here: | |
| # after equal rows both readers are at the same | |
| # position, so they cannot end for different | |
| # reasons. | |
| rows.append(None) | |
| if rows[0] != rows[1]: | |
| return True | |
| if rows == [None, None]: | |
| return False | |
| def _detect_skipinitialspace(self, lines, delimiter, quotechar, | |
| escapechar, doublequote): | |
| """ | |
| Detect whether the spaces following a delimiter are a part of | |
| the format or of the data. | |
| """ | |
| results = [] | |
| for skipinitialspace in False, True: | |
| rows = [] | |
| try: | |
| rows.extend(self._make_reader( | |
| lines, delimiter, quotechar, escapechar, | |
| doublequote=doublequote, | |
| skipinitialspace=skipinitialspace)) | |
| except Error: | |
| # Keep the rows parsed before the error. | |
| pass | |
| results.append([row for row in rows if row]) | |
| if results[0] == results[1]: | |
| return False # No evidence. | |
| counts = [[len(row) for row in rows] for rows in results] | |
| if counts[0] != counts[1]: | |
| # Prefer the more consistent row widths. | |
| return len(set(counts[1])) <= len(set(counts[0])) | |
| # Only some spaces are stripped. A field differs only if | |
| # a space was skipped at its start, which tells the padding | |
| # apart from the spaces inside quoted or escaped fields. | |
| if not all(kept_field != skipped_field | |
| for kept_row, skipped_row in zip(*results) | |
| for kept_field, skipped_field in zip(kept_row[1:], | |
| skipped_row[1:])): | |
| return False | |
| # The first field of a row is commonly not padded ('a, b, c'), | |
| # so the first fields need only agree with each other. | |
| first = [kept_row[0] != skipped_row[0] | |
| for kept_row, skipped_row in zip(*results)] | |
| return all(first) or not any(first) | |
| def _detect_lineterminator(self, lines): | |
| """ | |
| Detect the line terminator by majority vote among the line | |
| endings. A line break inside a quoted field is counted too, | |
| but it takes more of them than of the real ones to win the | |
| vote. A tie is broken in the order '\\r\\n', '\\n', '\\r', | |
| so a sample without a complete line gives '\\r\\n'. | |
| """ | |
| counts = dict.fromkeys(('\r\n', '\n', '\r'), 0) | |
| for line in lines: | |
| for lineterminator in counts: | |
| if line.endswith(lineterminator): | |
| counts[lineterminator] += 1 | |
| break | |
| # max() returns the first of equal candidates, and dict | |
| # preserves the insertion order. | |
| return max(counts, key=counts.get) | |
| def has_header(self, sample): | |
| # Creates a dictionary of types of data in each column. If any | |
| # column is of a single type (say, integers), *except* for the first | |
| # row, then the first row is presumed to be labels. If the type | |
| # can't be determined, it is assumed to be a string in which case | |
| # the length of the string is the determining factor: if all of the | |
| # rows except for the first are the same length, it's a header. | |
| # Finally, a 'vote' is taken at the end for each column, adding or | |
| # subtracting from the likelihood of the first row being a header. | |
| rdr = reader(StringIO(sample), self.sniff(sample)) | |
| header = next(rdr) # assume first row is header | |
| columns = len(header) | |
| columnTypes = {} | |
| for i in range(columns): columnTypes[i] = None | |
| checked = 0 | |
| for row in rdr: | |
| # arbitrary number of rows to check, to keep it sane | |
| if checked > 20: | |
| break | |
| checked += 1 | |
| if len(row) != columns: | |
| continue # skip rows that have irregular number of columns | |
| for col in list(columnTypes.keys()): | |
| thisType = complex | |
| try: | |
| thisType(row[col]) | |
| except (ValueError, OverflowError): | |
| # fallback to length of string | |
| thisType = len(row[col]) | |
| if thisType != columnTypes[col]: | |
| if columnTypes[col] is None: # add new column type | |
| columnTypes[col] = thisType | |
| else: | |
| # type is inconsistent, remove column from | |
| # consideration | |
| del columnTypes[col] | |
| # finally, compare results against first row and "vote" | |
| # on whether it's a header | |
| hasHeader = 0 | |
| for col, colType in columnTypes.items(): | |
| if isinstance(colType, int): # it's a length | |
| if len(header[col]) != colType: | |
| hasHeader += 1 | |
| else: | |
| hasHeader -= 1 | |
| else: # attempt typecast | |
| try: | |
| colType(header[col]) | |
| except (ValueError, TypeError): | |
| hasHeader += 1 | |
| else: | |
| hasHeader -= 1 | |
| return hasHeader > 0 | |
| def __getattr__(name): | |
| if name == "__version__": | |
| from warnings import _deprecated | |
| _deprecated("__version__", remove=(3, 20)) | |
| return "1.0" # Do not change | |
| raise AttributeError(f"module {__name__!r} has no attribute {name!r}") |