From 575ad686dcfeb307e75376b11d66d98c25850411 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 21:04:22 +0000 Subject: [PATCH 1/7] Add ENSV metadata layer with lazy parser interface Metadata (inert config): columns, types, bool sentinels, table width, unknown forms. Parses from/serializes to lifted metadata rows. ENSVReader: reusable stateful converter with settable .meta property. read() yields converted rows lazily with width validation. peel(): consume one element from an iterator, return (Metadata, tail). read(): one-shot convenience returning iterator with .meta attribute. encode(): metadata + data -> seqseq for nsv.dumps. https://claude.ai/code/session_018keUYHFdMPKVUXkfE4LdCs --- nsv/__init__.py | 1 + nsv/ensv.py | 278 ++++++++++++++++++ tests/test_ensv.py | 719 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 998 insertions(+) create mode 100644 tests/test_ensv.py diff --git a/nsv/__init__.py b/nsv/__init__.py index 46d7124..249f167 100644 --- a/nsv/__init__.py +++ b/nsv/__init__.py @@ -1,6 +1,7 @@ from .core import load, loads, dump, dumps from .reader import Reader from .writer import Writer +from . import ensv __version__ = "0.2.2" diff --git a/nsv/ensv.py b/nsv/ensv.py index 37704b1..0a3f64b 100644 --- a/nsv/ensv.py +++ b/nsv/ensv.py @@ -1,3 +1,8 @@ +"""ENSV -- metadata layer over NSV.""" + +import datetime +import uuid +import warnings from typing import List, Iterable from .reader import Reader @@ -43,3 +48,276 @@ def unlift(seq: Iterable[str]) -> List[List[str]]: row = [] rows.append(row) return rows + + +class UnknownForm: + def __init__(self, name, args): + self.name = name + self.args = list(args) + + def __eq__(self, other): + return (isinstance(other, UnknownForm) + and self.name == other.name + and self.args == other.args) + + def __repr__(self): + return f"UnknownForm({self.name!r}, {self.args!r})" + + +_TABLE_INFER = object() + + +class Metadata: + KNOWN_TYPES = frozenset({ + 'str', 'int', 'float', 'bool', 'date', 'datetime', 'uuid', + }) + + def __init__(self, *, columns=None, types=None, bool_sentinels=None, + table=None, unknown=None): + self.columns = list(columns) if columns is not None else None + self.types = list(types) if types is not None else None + self.bool = tuple(bool_sentinels) if bool_sentinels is not None else None + self.table = table + self.unknown = list(unknown) if unknown is not None else [] + self._validate_consistency() + + def _validate_consistency(self): + effective_width = self.table if isinstance(self.table, int) else None + + if self.columns is not None and effective_width is not None: + if len(self.columns) != effective_width: + raise ValueError( + f"columns: arity ({len(self.columns)}) does not match " + f"table width ({effective_width})") + if self.types is not None and effective_width is not None: + if len(self.types) != effective_width: + raise ValueError( + f"types: arity ({len(self.types)}) does not match " + f"table width ({effective_width})") + if self.columns is not None and self.types is not None: + if len(self.columns) != len(self.types): + raise ValueError( + f"columns: arity ({len(self.columns)}) does not match " + f"types: arity ({len(self.types)})") + if self.types is not None and 'bool' in self.types and self.bool is None: + raise ValueError( + "types: includes 'bool' but no bool form is present") + + @classmethod + def from_row(cls, row): + forms = unlift(row) + + columns = None + types = None + bool_sentinels = None + table = None + unknown = [] + + for form in forms: + if not form: + continue + name = form[0] + args = form[1:] + + if '/' in name: + raise ValueError( + f"namespaced form {name!r} is reserved for extensions") + + if name == 'columns:': + columns = args + elif name == 'types:': + types = args + elif name == 'bool': + if len(args) != 2: + raise ValueError( + f"bool form requires exactly 2 arguments, " + f"got {len(args)}") + bool_sentinels = (args[0], args[1]) + elif name == 'table': + if len(args) == 0: + table = _TABLE_INFER + elif len(args) == 1: + table = int(args[0]) + else: + raise ValueError( + f"table form takes 0 or 1 argument, got {len(args)}") + else: + unknown.append(UnknownForm(name, args)) + + return cls( + columns=columns, + types=types, + bool_sentinels=bool_sentinels, + table=table, + unknown=unknown, + ) + + def to_row(self): + forms = [] + if self.columns is not None: + forms.append(['columns:'] + self.columns) + if self.types is not None: + forms.append(['types:'] + self.types) + if self.bool is not None: + forms.append(['bool', self.bool[0], self.bool[1]]) + if self.table is _TABLE_INFER: + forms.append(['table']) + elif self.table is not None: + forms.append(['table', str(self.table)]) + for uf in self.unknown: + forms.append([uf.name] + uf.args) + return lift(forms) + + def __eq__(self, other): + return (isinstance(other, Metadata) + and self.columns == other.columns + and self.types == other.types + and self.bool == other.bool + and self.table == other.table + and self.unknown == other.unknown) + + def __repr__(self): + parts = [] + if self.columns is not None: + parts.append(f"columns={self.columns!r}") + if self.types is not None: + parts.append(f"types={self.types!r}") + if self.bool is not None: + parts.append(f"bool={self.bool!r}") + if self.table is not None: + parts.append(f"table={self.table!r}") + if self.unknown: + parts.append(f"unknown={self.unknown!r}") + return f"Metadata({', '.join(parts)})" + + +class ENSVReader: + def __init__(self, meta): + self._meta = meta + + @property + def meta(self): + return self._meta + + @meta.setter + def meta(self, value): + self._meta = value + + def read(self, rows): + meta = self._meta + table_width = meta.table if isinstance(meta.table, int) else None + + for i, row in enumerate(rows): + if meta.table is not None: + if table_width is None: + table_width = len(row) + elif len(row) != table_width: + raise ValueError( + f"row {i}: expected {table_width} columns, " + f"got {len(row)}") + + if meta.types is not None: + converted = [] + for j, cell in enumerate(row): + if j < len(meta.types): + converted.append( + _convert(cell, meta, j, i, j)) + else: + converted.append(cell) + yield converted + else: + yield list(row) + + +class _ReadResult: + def __init__(self, meta, iterator): + self.meta = meta + self._iterator = iterator + + def __iter__(self): + return self + + def __next__(self): + return next(self._iterator) + + +def peel(rows): + it = iter(rows) + try: + meta_row = next(it) + except StopIteration: + raise ValueError("ENSV data must have at least a metadata row") + return Metadata.from_row(meta_row), it + + +def read(rows): + meta, tail = peel(rows) + reader = ENSVReader(meta) + return _ReadResult(meta, reader.read(tail)) + + +def encode(metadata, data): + return [metadata.to_row()] + [list(row) for row in data] + + +def _convert(cell, meta, type_idx, row_idx, col_idx): + type_name = meta.types[type_idx] + + if type_name == 'str': + return cell + + if type_name == 'int': + try: + return int(cell) + except ValueError: + raise ValueError( + f"row {row_idx}, col {col_idx}: " + f"cannot convert {cell!r} to int") + + if type_name == 'float': + try: + return float(cell) + except ValueError: + raise ValueError( + f"row {row_idx}, col {col_idx}: " + f"cannot convert {cell!r} to float") + + if type_name == 'bool': + true_sentinel, false_sentinel = meta.bool + if cell == true_sentinel: + return True + if cell == false_sentinel: + return False + raise ValueError( + f"row {row_idx}, col {col_idx}: " + f"{cell!r} matches neither true sentinel " + f"{true_sentinel!r} nor false sentinel " + f"{false_sentinel!r}") + + if type_name == 'date': + try: + return datetime.date.fromisoformat(cell) + except ValueError: + raise ValueError( + f"row {row_idx}, col {col_idx}: " + f"cannot parse {cell!r} as ISO 8601 date") + + if type_name == 'datetime': + try: + return datetime.datetime.fromisoformat(cell) + except ValueError: + raise ValueError( + f"row {row_idx}, col {col_idx}: " + f"cannot parse {cell!r} as ISO 8601 datetime") + + if type_name == 'uuid': + try: + return uuid.UUID(cell) + except ValueError: + raise ValueError( + f"row {row_idx}, col {col_idx}: " + f"cannot parse {cell!r} as UUID") + + warnings.warn( + f"unknown type {type_name!r} at col {col_idx}, treating as str") + return cell diff --git a/tests/test_ensv.py b/tests/test_ensv.py new file mode 100644 index 0000000..06dbcf3 --- /dev/null +++ b/tests/test_ensv.py @@ -0,0 +1,719 @@ +import datetime +import unittest +import uuid +import warnings + +import nsv +from nsv.ensv import ( + Metadata, UnknownForm, ENSVReader, _TABLE_INFER, _ReadResult, + peel, read, encode, lift, +) + + +def _read(meta, data): + return list(ENSVReader(meta).read(data)) + + +# =================================================================== +# Metadata parsing +# =================================================================== + +class TestColumnsForm(unittest.TestCase): + + def test_columns_basic(self): + m = Metadata.from_row(lift([['columns:', 'name', 'age', 'city']])) + self.assertEqual(m.columns, ['name', 'age', 'city']) + + def test_columns_single(self): + m = Metadata.from_row(lift([['columns:', 'x']])) + self.assertEqual(m.columns, ['x']) + + def test_columns_with_spaces(self): + m = Metadata.from_row(lift([['columns:', 'col 1', 'col 2', 'col 3']])) + self.assertEqual(m.columns, ['col 1', 'col 2', 'col 3']) + + def test_columns_no_args(self): + m = Metadata.from_row(lift([['columns:']])) + self.assertEqual(m.columns, []) + + def test_columns_with_empty_names(self): + m = Metadata.from_row(lift([['columns:', '', '', '']])) + self.assertEqual(m.columns, ['', '', '']) + + +class TestTypesForm(unittest.TestCase): + + def test_types_basic(self): + m = Metadata.from_row(lift([['types:', 'str', 'int', 'float']])) + self.assertEqual(m.types, ['str', 'int', 'float']) + + def test_types_all_known(self): + m = Metadata.from_row(lift([ + ['types:', 'str', 'int', 'float', 'date', 'datetime', 'uuid'], + ])) + self.assertEqual( + m.types, + ['str', 'int', 'float', 'date', 'datetime', 'uuid']) + + def test_types_single(self): + m = Metadata.from_row(lift([['types:', 'int']])) + self.assertEqual(m.types, ['int']) + + +class TestBoolForm(unittest.TestCase): + + def test_bool_basic(self): + m = Metadata.from_row(lift([['bool', 'yes', 'no']])) + self.assertEqual(m.bool, ('yes', 'no')) + + def test_bool_true_false(self): + m = Metadata.from_row(lift([['bool', 'true', 'false']])) + self.assertEqual(m.bool, ('true', 'false')) + + def test_bool_wrong_arity(self): + with self.assertRaises(ValueError): + Metadata.from_row(lift([['bool', 'yes']])) + with self.assertRaises(ValueError): + Metadata.from_row(lift([['bool', 'a', 'b', 'c']])) + with self.assertRaises(ValueError): + Metadata.from_row(lift([['bool']])) + + +class TestTableForm(unittest.TestCase): + + def test_table_explicit_width(self): + m = Metadata.from_row(lift([['table', '5']])) + self.assertEqual(m.table, 5) + + def test_table_infer(self): + m = Metadata.from_row(lift([['table']])) + self.assertIs(m.table, _TABLE_INFER) + + def test_table_zero_width(self): + m = Metadata.from_row(lift([['table', '0']])) + self.assertEqual(m.table, 0) + + def test_table_too_many_args(self): + with self.assertRaises(ValueError): + Metadata.from_row(lift([['table', '3', '4']])) + + def test_no_table_form(self): + m = Metadata.from_row(lift([['columns:', 'a', 'b']])) + self.assertIsNone(m.table) + + +class TestUnknownForms(unittest.TestCase): + + def test_unknown_stashed(self): + m = Metadata.from_row(lift([['custom', 'arg1', 'arg2']])) + self.assertEqual(len(m.unknown), 1) + self.assertEqual(m.unknown[0], UnknownForm('custom', ['arg1', 'arg2'])) + + def test_multiple_unknown(self): + m = Metadata.from_row(lift([['foo', 'a'], ['bar', 'b', 'c']])) + self.assertEqual(len(m.unknown), 2) + self.assertEqual(m.unknown[0].name, 'foo') + self.assertEqual(m.unknown[1].name, 'bar') + + def test_namespaced_form_raises(self): + with self.assertRaises(ValueError) as cm: + Metadata.from_row(lift([['foo/bar', 'x']])) + self.assertIn('reserved', str(cm.exception)) + + def test_namespaced_deep_raises(self): + with self.assertRaises(ValueError): + Metadata.from_row(lift([['a/b/c']])) + + +class TestAllFormsTogether(unittest.TestCase): + + def test_all_forms(self): + m = Metadata.from_row(lift([ + ['columns:', 'name', 'active', 'score'], + ['types:', 'str', 'bool', 'int'], + ['bool', 'Y', 'N'], + ['table', '3'], + ])) + self.assertEqual(m.columns, ['name', 'active', 'score']) + self.assertEqual(m.types, ['str', 'bool', 'int']) + self.assertEqual(m.bool, ('Y', 'N')) + self.assertEqual(m.table, 3) + + def test_all_forms_with_unknown(self): + m = Metadata.from_row(lift([ + ['columns:', 'a'], ['types:', 'str'], ['bool', 't', 'f'], + ['table', '1'], ['version', '2'], + ])) + self.assertEqual(len(m.unknown), 1) + self.assertEqual(m.unknown[0].name, 'version') + + def test_partial_columns(self): + m = Metadata(columns=['a', 'b']) + self.assertEqual(_read(m, [['x', 'y', 'z']]), [['x', 'y', 'z']]) + + def test_partial_types(self): + m = Metadata(types=['int']) + self.assertEqual(_read(m, [['42', 'hello']]), [[42, 'hello']]) + + +# =================================================================== +# Arity agreement +# =================================================================== + +class TestArityAgreement(unittest.TestCase): + + def test_columns_types_mismatch(self): + with self.assertRaises(ValueError) as cm: + Metadata(columns=['a', 'b'], types=['str']) + self.assertIn('columns:', str(cm.exception)) + self.assertIn('types:', str(cm.exception)) + + def test_columns_table_mismatch(self): + with self.assertRaises(ValueError) as cm: + Metadata(columns=['a', 'b'], table=3) + self.assertIn('columns:', str(cm.exception)) + self.assertIn('table', str(cm.exception)) + + def test_types_table_mismatch(self): + with self.assertRaises(ValueError) as cm: + Metadata(types=['str', 'int'], table=3) + self.assertIn('types:', str(cm.exception)) + self.assertIn('table', str(cm.exception)) + + def test_all_three_agree(self): + m = Metadata(columns=['a', 'b'], types=['str', 'int'], table=2) + self.assertEqual(m.columns, ['a', 'b']) + + def test_columns_table_infer_no_error(self): + m = Metadata(columns=['a', 'b'], table=_TABLE_INFER) + self.assertEqual(m.columns, ['a', 'b']) + + +# =================================================================== +# Type conversion (via ENSVReader) +# =================================================================== + +class TestTypeConversionInt(unittest.TestCase): + + def test_positive(self): + self.assertEqual(_read(Metadata(types=['int']), [['42']]), [[42]]) + + def test_negative(self): + self.assertEqual(_read(Metadata(types=['int']), [['-7']]), [[-7]]) + + def test_zero(self): + self.assertEqual(_read(Metadata(types=['int']), [['0']]), [[0]]) + + def test_invalid(self): + with self.assertRaises(ValueError) as cm: + _read(Metadata(types=['int']), [['abc']]) + self.assertIn('row 0', str(cm.exception)) + self.assertIn('col 0', str(cm.exception)) + + +class TestTypeConversionFloat(unittest.TestCase): + + def test_positive(self): + self.assertEqual(_read(Metadata(types=['float']), [['3.14']]), [[3.14]]) + + def test_negative(self): + self.assertEqual(_read(Metadata(types=['float']), [['-2.5']]), [[-2.5]]) + + def test_zero(self): + self.assertEqual(_read(Metadata(types=['float']), [['0.0']]), [[0.0]]) + + def test_scientific(self): + self.assertEqual(_read(Metadata(types=['float']), [['1.5e10']]), [[1.5e10]]) + + def test_negative_scientific(self): + result = _read(Metadata(types=['float']), [['-3.2e-4']]) + self.assertAlmostEqual(result[0][0], -3.2e-4) + + def test_invalid(self): + with self.assertRaises(ValueError): + _read(Metadata(types=['float']), [['not_a_float']]) + + +class TestTypeConversionBool(unittest.TestCase): + + def test_true_sentinel(self): + m = Metadata(types=['bool'], bool_sentinels=('yes', 'no')) + self.assertEqual(_read(m, [['yes']]), [[True]]) + + def test_false_sentinel(self): + m = Metadata(types=['bool'], bool_sentinels=('yes', 'no')) + self.assertEqual(_read(m, [['no']]), [[False]]) + + def test_non_matching(self): + m = Metadata(types=['bool'], bool_sentinels=('yes', 'no')) + with self.assertRaises(ValueError) as cm: + _read(m, [['maybe']]) + self.assertIn('matches neither', str(cm.exception)) + + def test_case_sensitive(self): + m = Metadata(types=['bool'], bool_sentinels=('Yes', 'No')) + with self.assertRaises(ValueError): + _read(m, [['yes']]) + + def test_exact_match(self): + m = Metadata(types=['bool'], bool_sentinels=('Yes', 'No')) + with self.assertRaises(ValueError): + _read(m, [['Yes ']]) + + +class TestTypeConversionDate(unittest.TestCase): + + def test_valid(self): + m = Metadata(types=['date']) + self.assertEqual(_read(m, [['2024-03-15']]), [[datetime.date(2024, 3, 15)]]) + + def test_invalid(self): + with self.assertRaises(ValueError): + _read(Metadata(types=['date']), [['not-a-date']]) + + def test_invalid_format(self): + with self.assertRaises(ValueError): + _read(Metadata(types=['date']), [['03/15/2024']]) + + +class TestTypeConversionDatetime(unittest.TestCase): + + def test_without_timezone(self): + m = Metadata(types=['datetime']) + self.assertEqual( + _read(m, [['2024-03-15T10:30:00']]), + [[datetime.datetime(2024, 3, 15, 10, 30, 0)]]) + + def test_with_timezone(self): + m = Metadata(types=['datetime']) + result = _read(m, [['2024-03-15T10:30:00+05:00']]) + expected = datetime.datetime( + 2024, 3, 15, 10, 30, 0, + tzinfo=datetime.timezone(datetime.timedelta(hours=5))) + self.assertEqual(result[0][0], expected) + + def test_invalid(self): + with self.assertRaises(ValueError): + _read(Metadata(types=['datetime']), [['not-a-datetime']]) + + +class TestTypeConversionUUID(unittest.TestCase): + + def test_valid(self): + m = Metadata(types=['uuid']) + self.assertEqual( + _read(m, [['12345678-1234-5678-1234-567812345678']]), + [[uuid.UUID('12345678-1234-5678-1234-567812345678')]]) + + def test_invalid(self): + with self.assertRaises(ValueError): + _read(Metadata(types=['uuid']), [['not-a-uuid']]) + + +class TestTypeConversionStr(unittest.TestCase): + + def test_identity(self): + self.assertEqual(_read(Metadata(types=['str']), [['hello']]), [['hello']]) + + def test_empty_string(self): + self.assertEqual(_read(Metadata(types=['str']), [['']]), [['']]) + + +class TestTypeConversionUnknown(unittest.TestCase): + + def test_unknown_type_treated_as_str(self): + m = Metadata(types=['widget']) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = _read(m, [['hello']]) + self.assertEqual(result, [['hello']]) + self.assertEqual(len(w), 1) + self.assertIn('unknown type', str(w[0].message)) + self.assertIn('widget', str(w[0].message)) + + +# =================================================================== +# Table validation (via ENSVReader) +# =================================================================== + +class TestTableValidation(unittest.TestCase): + + def test_explicit_width_correct(self): + m = Metadata(table=3) + self.assertEqual( + _read(m, [['a', 'b', 'c'], ['d', 'e', 'f']]), + [['a', 'b', 'c'], ['d', 'e', 'f']]) + + def test_explicit_width_incorrect(self): + with self.assertRaises(ValueError) as cm: + _read(Metadata(table=3), [['a', 'b']]) + self.assertIn('row 0', str(cm.exception)) + + def test_explicit_width_second_row_wrong(self): + with self.assertRaises(ValueError): + _read(Metadata(table=2), [['a', 'b'], ['c']]) + + def test_infer_from_first_row(self): + m = Metadata(table=_TABLE_INFER) + self.assertEqual( + _read(m, [['a', 'b'], ['c', 'd']]), + [['a', 'b'], ['c', 'd']]) + + def test_infer_reject_ragged(self): + with self.assertRaises(ValueError): + _read(Metadata(table=_TABLE_INFER), [['a', 'b'], ['c']]) + + def test_no_table_ragged_ok(self): + self.assertEqual( + _read(Metadata(), [['a', 'b'], ['c']]), + [['a', 'b'], ['c']]) + + def test_infer_empty_data(self): + self.assertEqual(_read(Metadata(table=_TABLE_INFER), []), []) + + +# =================================================================== +# Bool form interaction +# =================================================================== + +class TestBoolInteraction(unittest.TestCase): + + def test_types_bool_without_bool_form_errors(self): + with self.assertRaises(ValueError) as cm: + Metadata(types=['bool']) + self.assertIn('bool', str(cm.exception)) + + def test_bool_form_without_bool_type_ok(self): + m = Metadata(types=['str'], bool_sentinels=('y', 'n')) + self.assertIsNotNone(m.bool) + + def test_bool_sentinels_exact(self): + m = Metadata(types=['bool'], bool_sentinels=('1', '0')) + self.assertEqual(_read(m, [['1']]), [[True]]) + self.assertEqual(_read(m, [['0']]), [[False]]) + with self.assertRaises(ValueError): + _read(m, [['1 ']]) + + +# =================================================================== +# ENSVReader +# =================================================================== + +class TestENSVReader(unittest.TestCase): + + def test_basic(self): + r = ENSVReader(Metadata(types=['int', 'str'])) + self.assertEqual( + list(r.read([['1', 'a'], ['2', 'b']])), + [[1, 'a'], [2, 'b']]) + + def test_shared_schema_two_iterables(self): + r = ENSVReader(Metadata(types=['int'])) + self.assertEqual(list(r.read([['1'], ['2']])), [[1], [2]]) + self.assertEqual(list(r.read([['3'], ['4']])), [[3], [4]]) + + def test_replace_meta_between_reads(self): + r = ENSVReader(Metadata(types=['int'])) + self.assertEqual(list(r.read([['42']])), [[42]]) + r.meta = Metadata(types=['float']) + self.assertEqual(list(r.read([['3.14']])), [[3.14]]) + + def test_meta_property(self): + m = Metadata(types=['str']) + r = ENSVReader(m) + self.assertIs(r.meta, m) + + def test_width_resets_between_reads(self): + r = ENSVReader(Metadata(table=_TABLE_INFER)) + self.assertEqual(list(r.read([['a', 'b']])), [['a', 'b']]) + self.assertEqual(list(r.read([['x', 'y', 'z']])), [['x', 'y', 'z']]) + + def test_no_types_passthrough(self): + r = ENSVReader(Metadata()) + self.assertEqual(list(r.read([['a', 'b']])), [['a', 'b']]) + + +# =================================================================== +# peel +# =================================================================== + +class TestPeel(unittest.TestCase): + + def test_peel_from_list(self): + meta_row = lift([['types:', 'int']]) + meta, tail = peel([meta_row, ['42']]) + self.assertEqual(meta.types, ['int']) + self.assertEqual(list(tail), [['42']]) + + def test_peel_from_generator(self): + consumed = [] + def gen(): + consumed.append('meta') + yield lift([['types:', 'int']]) + consumed.append('row1') + yield ['42'] + consumed.append('row2') + yield ['99'] + + meta, tail = peel(gen()) + self.assertEqual(consumed, ['meta']) + self.assertEqual(meta.types, ['int']) + row1 = next(tail) + self.assertEqual(consumed, ['meta', 'row1']) + self.assertEqual(row1, ['42']) + + def test_peel_empty_raises(self): + with self.assertRaises(ValueError): + peel([]) + + def test_peel_metadata_only(self): + meta, tail = peel([lift([['columns:', 'a']])]) + self.assertEqual(meta.columns, ['a']) + self.assertEqual(list(tail), []) + + +# =================================================================== +# ensv.read (one-shot) +# =================================================================== + +class TestRead(unittest.TestCase): + + def test_one_shot(self): + meta_row = lift([['types:', 'int', 'str']]) + result = read([meta_row, ['1', 'a'], ['2', 'b']]) + self.assertEqual(list(result), [[1, 'a'], [2, 'b']]) + + def test_meta_accessible(self): + meta_row = lift([['columns:', 'x', 'y']]) + result = read([meta_row, ['1', '2']]) + self.assertEqual(result.meta.columns, ['x', 'y']) + self.assertEqual(list(result), [['1', '2']]) + + def test_usable_in_for_loop(self): + meta_row = lift([['types:', 'int']]) + rows = [] + for row in read([meta_row, ['1'], ['2']]): + rows.append(row) + self.assertEqual(rows, [[1], [2]]) + + def test_is_iterator(self): + result = read([lift([['columns:', 'a']]), ['x']]) + self.assertIs(iter(result), result) + + def test_empty_data(self): + result = read([lift([['types:', 'int']])]) + self.assertEqual(list(result), []) + + def test_empty_raises(self): + with self.assertRaises(ValueError): + read([]) + + +# =================================================================== +# Laziness +# =================================================================== + +class TestLaziness(unittest.TestCase): + + def test_reader_read_is_lazy(self): + consumed = [] + def gen(): + for i in range(5): + consumed.append(i) + yield [str(i)] + + r = ENSVReader(Metadata(types=['int'])) + it = r.read(gen()) + self.assertEqual(consumed, []) + self.assertEqual(next(it), [0]) + self.assertEqual(consumed, [0]) + self.assertEqual(next(it), [1]) + self.assertEqual(consumed, [0, 1]) + + def test_read_is_lazy(self): + consumed = [] + def gen(): + consumed.append('meta') + yield lift([['types:', 'int']]) + for i in range(3): + consumed.append(i) + yield [str(i)] + + result = read(gen()) + self.assertEqual(consumed, ['meta']) + self.assertEqual(next(result), [0]) + self.assertEqual(consumed, ['meta', 0]) + + def test_peel_consumes_exactly_one(self): + consumed = [] + def gen(): + for i in range(3): + consumed.append(i) + yield [str(i)] + + meta, tail = peel(gen()) + self.assertEqual(len(consumed), 1) + + +# =================================================================== +# Round-trip +# =================================================================== + +class TestRoundTrip(unittest.TestCase): + + def _roundtrip(self, meta): + self.assertEqual(Metadata.from_row(meta.to_row()), meta) + + def test_empty_metadata(self): + self._roundtrip(Metadata()) + + def test_columns_only(self): + self._roundtrip(Metadata(columns=['a', 'b', 'c'])) + + def test_types_only(self): + self._roundtrip(Metadata(types=['str', 'int', 'float'])) + + def test_bool_only(self): + self._roundtrip(Metadata(bool_sentinels=('yes', 'no'))) + + def test_table_explicit(self): + self._roundtrip(Metadata(table=5)) + + def test_table_infer(self): + self._roundtrip(Metadata(table=_TABLE_INFER)) + + def test_unknown_forms(self): + self._roundtrip(Metadata(unknown=[UnknownForm('custom', ['x', 'y'])])) + + def test_all_forms(self): + self._roundtrip(Metadata( + columns=['a', 'b'], types=['str', 'int'], + bool_sentinels=('t', 'f'), table=2, + unknown=[UnknownForm('extra', ['val'])], + )) + + def test_columns_with_special_chars(self): + self._roundtrip(Metadata(columns=['hello world', 'a,b', 'x\ny'])) + + def test_columns_with_empty_names(self): + self._roundtrip(Metadata(columns=['', 'b', ''])) + + def test_bool_sentinels_with_empty_string(self): + self._roundtrip(Metadata(bool_sentinels=('', 'no'))) + + def test_unknown_form_with_empty_args(self): + self._roundtrip(Metadata(unknown=[UnknownForm('nullable', ['', 'NULL'])])) + + def test_nsv_full_roundtrip(self): + meta = Metadata(columns=['name', 'score'], types=['str', 'int'], table=2) + data = [['Alice', '100'], ['Bob', '200']] + + ensv_seqseq = encode(meta, data) + nsv_string = nsv.dumps(ensv_seqseq) + recovered_seqseq = nsv.loads(nsv_string) + + result = read(recovered_seqseq) + self.assertEqual(result.meta, meta) + self.assertEqual(list(result), [['Alice', 100], ['Bob', 200]]) + + +# =================================================================== +# Sidecared metadata +# =================================================================== + +class TestSidecaredMetadata(unittest.TestCase): + + def test_sidecar_same_as_self_descriptive(self): + meta = Metadata( + columns=['id', 'name', 'active'], + types=['int', 'str', 'bool'], + bool_sentinels=('Y', 'N'), table=3, + ) + data = [['1', 'Alice', 'Y'], ['2', 'Bob', 'N']] + result = _read(meta, data) + self.assertEqual(result, [[1, 'Alice', True], [2, 'Bob', False]]) + + ensv_seqseq = [meta.to_row()] + data + result2 = list(read(ensv_seqseq)) + self.assertEqual(result, result2) + + def test_sidecar_types_only(self): + m = Metadata(types=['float', 'float']) + self.assertEqual( + _read(m, [['1.5', '2.5'], ['3.0', '4.0']]), + [[1.5, 2.5], [3.0, 4.0]]) + + def test_sidecar_table_validation(self): + with self.assertRaises(ValueError): + _read(Metadata(table=2), [['a', 'b', 'c']]) + + def test_sidecar_no_metadata(self): + self.assertEqual( + _read(Metadata(), [['a', 'b'], ['c']]), + [['a', 'b'], ['c']]) + + +# =================================================================== +# encode +# =================================================================== + +class TestEncode(unittest.TestCase): + + def test_encode_produces_valid_seqseq(self): + meta = Metadata(columns=['x']) + seqseq = encode(meta, [['1'], ['2']]) + self.assertEqual(len(seqseq), 3) + + def test_full_pipeline(self): + meta = Metadata(types=['str', 'int', 'float', 'date'], table=4) + data = [ + ['hello', '42', '3.14', '2024-01-01'], + ['world', '-1', '0.0', '2024-12-31'], + ] + ensv_seqseq = encode(meta, data) + nsv_string = nsv.dumps(ensv_seqseq) + recovered = nsv.loads(nsv_string) + result = read(recovered) + + self.assertEqual(result.meta, meta) + rows = list(result) + self.assertEqual(rows[0][0], 'hello') + self.assertEqual(rows[0][1], 42) + self.assertAlmostEqual(rows[0][2], 3.14) + self.assertEqual(rows[0][3], datetime.date(2024, 1, 1)) + + +# =================================================================== +# Edge cases +# =================================================================== + +class TestEdgeCases(unittest.TestCase): + + def test_empty_metadata_row(self): + meta = Metadata.from_row([]) + self.assertIsNone(meta.columns) + self.assertIsNone(meta.types) + self.assertIsNone(meta.bool) + self.assertIsNone(meta.table) + self.assertEqual(meta.unknown, []) + + def test_multiple_type_conversions_per_row(self): + m = Metadata(types=['int', 'float', 'str'], bool_sentinels=('t', 'f')) + self.assertEqual(_read(m, [['10', '2.5', 'hello']]), [[10, 2.5, 'hello']]) + + def test_conversion_error_includes_indices(self): + m = Metadata(types=['str', 'int', 'str']) + with self.assertRaises(ValueError) as cm: + _read(m, [['ok', 'bad', 'ok']]) + msg = str(cm.exception) + self.assertIn('row 0', msg) + self.assertIn('col 1', msg) + + def test_bool_sentinels_can_be_empty_string(self): + m = Metadata(types=['bool'], bool_sentinels=('', 'n')) + self.assertEqual(_read(m, [['']]), [[True]]) + self.assertEqual(_read(m, [['n']]), [[False]]) + + +if __name__ == '__main__': + unittest.main() From 5b9dec094fab78f356cb22178ea7ba624b96da1a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 22:50:35 +0000 Subject: [PATCH 2/7] Clean up UnknownForm and replace _TABLE_INFER sentinel - Remove __eq__/__repr__ from UnknownForm (test concern, not live code) - Split overloaded table field into table (bool) + width (int|None) - Compare unknown forms by fields in Metadata.__eq__ https://claude.ai/code/session_018keUYHFdMPKVUXkfE4LdCs --- nsv/ensv.py | 68 ++++++++++++++++++++++++---------------------- tests/test_ensv.py | 57 ++++++++++++++++++++------------------ 2 files changed, 66 insertions(+), 59 deletions(-) diff --git a/nsv/ensv.py b/nsv/ensv.py index 0a3f64b..13f6ea9 100644 --- a/nsv/ensv.py +++ b/nsv/ensv.py @@ -55,17 +55,6 @@ def __init__(self, name, args): self.name = name self.args = list(args) - def __eq__(self, other): - return (isinstance(other, UnknownForm) - and self.name == other.name - and self.args == other.args) - - def __repr__(self): - return f"UnknownForm({self.name!r}, {self.args!r})" - - -_TABLE_INFER = object() - class Metadata: KNOWN_TYPES = frozenset({ @@ -73,27 +62,29 @@ class Metadata: }) def __init__(self, *, columns=None, types=None, bool_sentinels=None, - table=None, unknown=None): + table=False, width=None, unknown=None): self.columns = list(columns) if columns is not None else None self.types = list(types) if types is not None else None self.bool = tuple(bool_sentinels) if bool_sentinels is not None else None self.table = table + self.width = width self.unknown = list(unknown) if unknown is not None else [] self._validate_consistency() def _validate_consistency(self): - effective_width = self.table if isinstance(self.table, int) else None + if self.width is not None and not self.table: + raise ValueError("width requires table=True") - if self.columns is not None and effective_width is not None: - if len(self.columns) != effective_width: + if self.columns is not None and self.width is not None: + if len(self.columns) != self.width: raise ValueError( f"columns: arity ({len(self.columns)}) does not match " - f"table width ({effective_width})") - if self.types is not None and effective_width is not None: - if len(self.types) != effective_width: + f"table width ({self.width})") + if self.types is not None and self.width is not None: + if len(self.types) != self.width: raise ValueError( f"types: arity ({len(self.types)}) does not match " - f"table width ({effective_width})") + f"table width ({self.width})") if self.columns is not None and self.types is not None: if len(self.columns) != len(self.types): raise ValueError( @@ -110,7 +101,8 @@ def from_row(cls, row): columns = None types = None bool_sentinels = None - table = None + table = False + width = None unknown = [] for form in forms: @@ -134,10 +126,11 @@ def from_row(cls, row): f"got {len(args)}") bool_sentinels = (args[0], args[1]) elif name == 'table': + table = True if len(args) == 0: - table = _TABLE_INFER + width = None elif len(args) == 1: - table = int(args[0]) + width = int(args[0]) else: raise ValueError( f"table form takes 0 or 1 argument, got {len(args)}") @@ -149,6 +142,7 @@ def from_row(cls, row): types=types, bool_sentinels=bool_sentinels, table=table, + width=width, unknown=unknown, ) @@ -160,21 +154,27 @@ def to_row(self): forms.append(['types:'] + self.types) if self.bool is not None: forms.append(['bool', self.bool[0], self.bool[1]]) - if self.table is _TABLE_INFER: - forms.append(['table']) - elif self.table is not None: - forms.append(['table', str(self.table)]) + if self.table: + if self.width is not None: + forms.append(['table', str(self.width)]) + else: + forms.append(['table']) for uf in self.unknown: forms.append([uf.name] + uf.args) return lift(forms) def __eq__(self, other): - return (isinstance(other, Metadata) - and self.columns == other.columns + if not isinstance(other, Metadata): + return NotImplemented + if not (self.columns == other.columns and self.types == other.types and self.bool == other.bool and self.table == other.table - and self.unknown == other.unknown) + and self.width == other.width + and len(self.unknown) == len(other.unknown)): + return False + return all(a.name == b.name and a.args == b.args + for a, b in zip(self.unknown, other.unknown)) def __repr__(self): parts = [] @@ -184,8 +184,10 @@ def __repr__(self): parts.append(f"types={self.types!r}") if self.bool is not None: parts.append(f"bool={self.bool!r}") - if self.table is not None: - parts.append(f"table={self.table!r}") + if self.table: + parts.append(f"table=True") + if self.width is not None: + parts.append(f"width={self.width!r}") if self.unknown: parts.append(f"unknown={self.unknown!r}") return f"Metadata({', '.join(parts)})" @@ -205,10 +207,10 @@ def meta(self, value): def read(self, rows): meta = self._meta - table_width = meta.table if isinstance(meta.table, int) else None + table_width = meta.width for i, row in enumerate(rows): - if meta.table is not None: + if meta.table: if table_width is None: table_width = len(row) elif len(row) != table_width: diff --git a/tests/test_ensv.py b/tests/test_ensv.py index 06dbcf3..3bdf1b7 100644 --- a/tests/test_ensv.py +++ b/tests/test_ensv.py @@ -5,7 +5,7 @@ import nsv from nsv.ensv import ( - Metadata, UnknownForm, ENSVReader, _TABLE_INFER, _ReadResult, + Metadata, UnknownForm, ENSVReader, _ReadResult, peel, read, encode, lift, ) @@ -83,15 +83,18 @@ class TestTableForm(unittest.TestCase): def test_table_explicit_width(self): m = Metadata.from_row(lift([['table', '5']])) - self.assertEqual(m.table, 5) + self.assertTrue(m.table) + self.assertEqual(m.width, 5) def test_table_infer(self): m = Metadata.from_row(lift([['table']])) - self.assertIs(m.table, _TABLE_INFER) + self.assertTrue(m.table) + self.assertIsNone(m.width) def test_table_zero_width(self): m = Metadata.from_row(lift([['table', '0']])) - self.assertEqual(m.table, 0) + self.assertTrue(m.table) + self.assertEqual(m.width, 0) def test_table_too_many_args(self): with self.assertRaises(ValueError): @@ -99,7 +102,7 @@ def test_table_too_many_args(self): def test_no_table_form(self): m = Metadata.from_row(lift([['columns:', 'a', 'b']])) - self.assertIsNone(m.table) + self.assertFalse(m.table) class TestUnknownForms(unittest.TestCase): @@ -107,7 +110,8 @@ class TestUnknownForms(unittest.TestCase): def test_unknown_stashed(self): m = Metadata.from_row(lift([['custom', 'arg1', 'arg2']])) self.assertEqual(len(m.unknown), 1) - self.assertEqual(m.unknown[0], UnknownForm('custom', ['arg1', 'arg2'])) + self.assertEqual(m.unknown[0].name, 'custom') + self.assertEqual(m.unknown[0].args, ['arg1', 'arg2']) def test_multiple_unknown(self): m = Metadata.from_row(lift([['foo', 'a'], ['bar', 'b', 'c']])) @@ -137,7 +141,8 @@ def test_all_forms(self): self.assertEqual(m.columns, ['name', 'active', 'score']) self.assertEqual(m.types, ['str', 'bool', 'int']) self.assertEqual(m.bool, ('Y', 'N')) - self.assertEqual(m.table, 3) + self.assertTrue(m.table) + self.assertEqual(m.width, 3) def test_all_forms_with_unknown(self): m = Metadata.from_row(lift([ @@ -170,22 +175,22 @@ def test_columns_types_mismatch(self): def test_columns_table_mismatch(self): with self.assertRaises(ValueError) as cm: - Metadata(columns=['a', 'b'], table=3) + Metadata(columns=['a', 'b'], table=True, width=3) self.assertIn('columns:', str(cm.exception)) self.assertIn('table', str(cm.exception)) def test_types_table_mismatch(self): with self.assertRaises(ValueError) as cm: - Metadata(types=['str', 'int'], table=3) + Metadata(types=['str', 'int'], table=True, width=3) self.assertIn('types:', str(cm.exception)) self.assertIn('table', str(cm.exception)) def test_all_three_agree(self): - m = Metadata(columns=['a', 'b'], types=['str', 'int'], table=2) + m = Metadata(columns=['a', 'b'], types=['str', 'int'], table=True, width=2) self.assertEqual(m.columns, ['a', 'b']) def test_columns_table_infer_no_error(self): - m = Metadata(columns=['a', 'b'], table=_TABLE_INFER) + m = Metadata(columns=['a', 'b'], table=True) self.assertEqual(m.columns, ['a', 'b']) @@ -339,29 +344,29 @@ def test_unknown_type_treated_as_str(self): class TestTableValidation(unittest.TestCase): def test_explicit_width_correct(self): - m = Metadata(table=3) + m = Metadata(table=True, width=3) self.assertEqual( _read(m, [['a', 'b', 'c'], ['d', 'e', 'f']]), [['a', 'b', 'c'], ['d', 'e', 'f']]) def test_explicit_width_incorrect(self): with self.assertRaises(ValueError) as cm: - _read(Metadata(table=3), [['a', 'b']]) + _read(Metadata(table=True, width=3), [['a', 'b']]) self.assertIn('row 0', str(cm.exception)) def test_explicit_width_second_row_wrong(self): with self.assertRaises(ValueError): - _read(Metadata(table=2), [['a', 'b'], ['c']]) + _read(Metadata(table=True, width=2), [['a', 'b'], ['c']]) def test_infer_from_first_row(self): - m = Metadata(table=_TABLE_INFER) + m = Metadata(table=True) self.assertEqual( _read(m, [['a', 'b'], ['c', 'd']]), [['a', 'b'], ['c', 'd']]) def test_infer_reject_ragged(self): with self.assertRaises(ValueError): - _read(Metadata(table=_TABLE_INFER), [['a', 'b'], ['c']]) + _read(Metadata(table=True), [['a', 'b'], ['c']]) def test_no_table_ragged_ok(self): self.assertEqual( @@ -369,7 +374,7 @@ def test_no_table_ragged_ok(self): [['a', 'b'], ['c']]) def test_infer_empty_data(self): - self.assertEqual(_read(Metadata(table=_TABLE_INFER), []), []) + self.assertEqual(_read(Metadata(table=True), []), []) # =================================================================== @@ -424,7 +429,7 @@ def test_meta_property(self): self.assertIs(r.meta, m) def test_width_resets_between_reads(self): - r = ENSVReader(Metadata(table=_TABLE_INFER)) + r = ENSVReader(Metadata(table=True)) self.assertEqual(list(r.read([['a', 'b']])), [['a', 'b']]) self.assertEqual(list(r.read([['x', 'y', 'z']])), [['x', 'y', 'z']]) @@ -577,10 +582,10 @@ def test_bool_only(self): self._roundtrip(Metadata(bool_sentinels=('yes', 'no'))) def test_table_explicit(self): - self._roundtrip(Metadata(table=5)) + self._roundtrip(Metadata(table=True, width=5)) def test_table_infer(self): - self._roundtrip(Metadata(table=_TABLE_INFER)) + self._roundtrip(Metadata(table=True)) def test_unknown_forms(self): self._roundtrip(Metadata(unknown=[UnknownForm('custom', ['x', 'y'])])) @@ -588,7 +593,7 @@ def test_unknown_forms(self): def test_all_forms(self): self._roundtrip(Metadata( columns=['a', 'b'], types=['str', 'int'], - bool_sentinels=('t', 'f'), table=2, + bool_sentinels=('t', 'f'), table=True, width=2, unknown=[UnknownForm('extra', ['val'])], )) @@ -605,7 +610,7 @@ def test_unknown_form_with_empty_args(self): self._roundtrip(Metadata(unknown=[UnknownForm('nullable', ['', 'NULL'])])) def test_nsv_full_roundtrip(self): - meta = Metadata(columns=['name', 'score'], types=['str', 'int'], table=2) + meta = Metadata(columns=['name', 'score'], types=['str', 'int'], table=True, width=2) data = [['Alice', '100'], ['Bob', '200']] ensv_seqseq = encode(meta, data) @@ -627,7 +632,7 @@ def test_sidecar_same_as_self_descriptive(self): meta = Metadata( columns=['id', 'name', 'active'], types=['int', 'str', 'bool'], - bool_sentinels=('Y', 'N'), table=3, + bool_sentinels=('Y', 'N'), table=True, width=3, ) data = [['1', 'Alice', 'Y'], ['2', 'Bob', 'N']] result = _read(meta, data) @@ -645,7 +650,7 @@ def test_sidecar_types_only(self): def test_sidecar_table_validation(self): with self.assertRaises(ValueError): - _read(Metadata(table=2), [['a', 'b', 'c']]) + _read(Metadata(table=True, width=2), [['a', 'b', 'c']]) def test_sidecar_no_metadata(self): self.assertEqual( @@ -665,7 +670,7 @@ def test_encode_produces_valid_seqseq(self): self.assertEqual(len(seqseq), 3) def test_full_pipeline(self): - meta = Metadata(types=['str', 'int', 'float', 'date'], table=4) + meta = Metadata(types=['str', 'int', 'float', 'date'], table=True, width=4) data = [ ['hello', '42', '3.14', '2024-01-01'], ['world', '-1', '0.0', '2024-12-31'], @@ -694,7 +699,7 @@ def test_empty_metadata_row(self): self.assertIsNone(meta.columns) self.assertIsNone(meta.types) self.assertIsNone(meta.bool) - self.assertIsNone(meta.table) + self.assertFalse(meta.table) self.assertEqual(meta.unknown, []) def test_multiple_type_conversions_per_row(self): From 6ec8a5e9be5ab628b78119fff15b06ca904658f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 16:05:23 +0000 Subject: [PATCH 3/7] Descope ENSV to just peel: unlift metadata row to seqseq Remove Metadata, ENSVReader, type conversion, encode, and all associated machinery. The module now exports lift, unlift, and peel. peel consumes one row from an iterator and returns (seqseq, tail). https://claude.ai/code/session_018keUYHFdMPKVUXkfE4LdCs --- nsv/ensv.py | 271 +---------------- tests/test_ensv.py | 713 ++------------------------------------------- 2 files changed, 29 insertions(+), 955 deletions(-) diff --git a/nsv/ensv.py b/nsv/ensv.py index 13f6ea9..b447961 100644 --- a/nsv/ensv.py +++ b/nsv/ensv.py @@ -1,8 +1,5 @@ """ENSV -- metadata layer over NSV.""" -import datetime -import uuid -import warnings from typing import List, Iterable from .reader import Reader @@ -50,276 +47,10 @@ def unlift(seq: Iterable[str]) -> List[List[str]]: return rows -class UnknownForm: - def __init__(self, name, args): - self.name = name - self.args = list(args) - - -class Metadata: - KNOWN_TYPES = frozenset({ - 'str', 'int', 'float', 'bool', 'date', 'datetime', 'uuid', - }) - - def __init__(self, *, columns=None, types=None, bool_sentinels=None, - table=False, width=None, unknown=None): - self.columns = list(columns) if columns is not None else None - self.types = list(types) if types is not None else None - self.bool = tuple(bool_sentinels) if bool_sentinels is not None else None - self.table = table - self.width = width - self.unknown = list(unknown) if unknown is not None else [] - self._validate_consistency() - - def _validate_consistency(self): - if self.width is not None and not self.table: - raise ValueError("width requires table=True") - - if self.columns is not None and self.width is not None: - if len(self.columns) != self.width: - raise ValueError( - f"columns: arity ({len(self.columns)}) does not match " - f"table width ({self.width})") - if self.types is not None and self.width is not None: - if len(self.types) != self.width: - raise ValueError( - f"types: arity ({len(self.types)}) does not match " - f"table width ({self.width})") - if self.columns is not None and self.types is not None: - if len(self.columns) != len(self.types): - raise ValueError( - f"columns: arity ({len(self.columns)}) does not match " - f"types: arity ({len(self.types)})") - if self.types is not None and 'bool' in self.types and self.bool is None: - raise ValueError( - "types: includes 'bool' but no bool form is present") - - @classmethod - def from_row(cls, row): - forms = unlift(row) - - columns = None - types = None - bool_sentinels = None - table = False - width = None - unknown = [] - - for form in forms: - if not form: - continue - name = form[0] - args = form[1:] - - if '/' in name: - raise ValueError( - f"namespaced form {name!r} is reserved for extensions") - - if name == 'columns:': - columns = args - elif name == 'types:': - types = args - elif name == 'bool': - if len(args) != 2: - raise ValueError( - f"bool form requires exactly 2 arguments, " - f"got {len(args)}") - bool_sentinels = (args[0], args[1]) - elif name == 'table': - table = True - if len(args) == 0: - width = None - elif len(args) == 1: - width = int(args[0]) - else: - raise ValueError( - f"table form takes 0 or 1 argument, got {len(args)}") - else: - unknown.append(UnknownForm(name, args)) - - return cls( - columns=columns, - types=types, - bool_sentinels=bool_sentinels, - table=table, - width=width, - unknown=unknown, - ) - - def to_row(self): - forms = [] - if self.columns is not None: - forms.append(['columns:'] + self.columns) - if self.types is not None: - forms.append(['types:'] + self.types) - if self.bool is not None: - forms.append(['bool', self.bool[0], self.bool[1]]) - if self.table: - if self.width is not None: - forms.append(['table', str(self.width)]) - else: - forms.append(['table']) - for uf in self.unknown: - forms.append([uf.name] + uf.args) - return lift(forms) - - def __eq__(self, other): - if not isinstance(other, Metadata): - return NotImplemented - if not (self.columns == other.columns - and self.types == other.types - and self.bool == other.bool - and self.table == other.table - and self.width == other.width - and len(self.unknown) == len(other.unknown)): - return False - return all(a.name == b.name and a.args == b.args - for a, b in zip(self.unknown, other.unknown)) - - def __repr__(self): - parts = [] - if self.columns is not None: - parts.append(f"columns={self.columns!r}") - if self.types is not None: - parts.append(f"types={self.types!r}") - if self.bool is not None: - parts.append(f"bool={self.bool!r}") - if self.table: - parts.append(f"table=True") - if self.width is not None: - parts.append(f"width={self.width!r}") - if self.unknown: - parts.append(f"unknown={self.unknown!r}") - return f"Metadata({', '.join(parts)})" - - -class ENSVReader: - def __init__(self, meta): - self._meta = meta - - @property - def meta(self): - return self._meta - - @meta.setter - def meta(self, value): - self._meta = value - - def read(self, rows): - meta = self._meta - table_width = meta.width - - for i, row in enumerate(rows): - if meta.table: - if table_width is None: - table_width = len(row) - elif len(row) != table_width: - raise ValueError( - f"row {i}: expected {table_width} columns, " - f"got {len(row)}") - - if meta.types is not None: - converted = [] - for j, cell in enumerate(row): - if j < len(meta.types): - converted.append( - _convert(cell, meta, j, i, j)) - else: - converted.append(cell) - yield converted - else: - yield list(row) - - -class _ReadResult: - def __init__(self, meta, iterator): - self.meta = meta - self._iterator = iterator - - def __iter__(self): - return self - - def __next__(self): - return next(self._iterator) - - def peel(rows): it = iter(rows) try: meta_row = next(it) except StopIteration: raise ValueError("ENSV data must have at least a metadata row") - return Metadata.from_row(meta_row), it - - -def read(rows): - meta, tail = peel(rows) - reader = ENSVReader(meta) - return _ReadResult(meta, reader.read(tail)) - - -def encode(metadata, data): - return [metadata.to_row()] + [list(row) for row in data] - - -def _convert(cell, meta, type_idx, row_idx, col_idx): - type_name = meta.types[type_idx] - - if type_name == 'str': - return cell - - if type_name == 'int': - try: - return int(cell) - except ValueError: - raise ValueError( - f"row {row_idx}, col {col_idx}: " - f"cannot convert {cell!r} to int") - - if type_name == 'float': - try: - return float(cell) - except ValueError: - raise ValueError( - f"row {row_idx}, col {col_idx}: " - f"cannot convert {cell!r} to float") - - if type_name == 'bool': - true_sentinel, false_sentinel = meta.bool - if cell == true_sentinel: - return True - if cell == false_sentinel: - return False - raise ValueError( - f"row {row_idx}, col {col_idx}: " - f"{cell!r} matches neither true sentinel " - f"{true_sentinel!r} nor false sentinel " - f"{false_sentinel!r}") - - if type_name == 'date': - try: - return datetime.date.fromisoformat(cell) - except ValueError: - raise ValueError( - f"row {row_idx}, col {col_idx}: " - f"cannot parse {cell!r} as ISO 8601 date") - - if type_name == 'datetime': - try: - return datetime.datetime.fromisoformat(cell) - except ValueError: - raise ValueError( - f"row {row_idx}, col {col_idx}: " - f"cannot parse {cell!r} as ISO 8601 datetime") - - if type_name == 'uuid': - try: - return uuid.UUID(cell) - except ValueError: - raise ValueError( - f"row {row_idx}, col {col_idx}: " - f"cannot parse {cell!r} as UUID") - - warnings.warn( - f"unknown type {type_name!r} at col {col_idx}, treating as str") - return cell + return unlift(meta_row), it diff --git a/tests/test_ensv.py b/tests/test_ensv.py index 3bdf1b7..7c5cd9f 100644 --- a/tests/test_ensv.py +++ b/tests/test_ensv.py @@ -1,453 +1,14 @@ -import datetime import unittest -import uuid -import warnings -import nsv -from nsv.ensv import ( - Metadata, UnknownForm, ENSVReader, _ReadResult, - peel, read, encode, lift, -) +from nsv.ensv import lift, unlift, peel -def _read(meta, data): - return list(ENSVReader(meta).read(data)) - - -# =================================================================== -# Metadata parsing -# =================================================================== - -class TestColumnsForm(unittest.TestCase): - - def test_columns_basic(self): - m = Metadata.from_row(lift([['columns:', 'name', 'age', 'city']])) - self.assertEqual(m.columns, ['name', 'age', 'city']) - - def test_columns_single(self): - m = Metadata.from_row(lift([['columns:', 'x']])) - self.assertEqual(m.columns, ['x']) - - def test_columns_with_spaces(self): - m = Metadata.from_row(lift([['columns:', 'col 1', 'col 2', 'col 3']])) - self.assertEqual(m.columns, ['col 1', 'col 2', 'col 3']) - - def test_columns_no_args(self): - m = Metadata.from_row(lift([['columns:']])) - self.assertEqual(m.columns, []) - - def test_columns_with_empty_names(self): - m = Metadata.from_row(lift([['columns:', '', '', '']])) - self.assertEqual(m.columns, ['', '', '']) - - -class TestTypesForm(unittest.TestCase): - - def test_types_basic(self): - m = Metadata.from_row(lift([['types:', 'str', 'int', 'float']])) - self.assertEqual(m.types, ['str', 'int', 'float']) - - def test_types_all_known(self): - m = Metadata.from_row(lift([ - ['types:', 'str', 'int', 'float', 'date', 'datetime', 'uuid'], - ])) - self.assertEqual( - m.types, - ['str', 'int', 'float', 'date', 'datetime', 'uuid']) - - def test_types_single(self): - m = Metadata.from_row(lift([['types:', 'int']])) - self.assertEqual(m.types, ['int']) - - -class TestBoolForm(unittest.TestCase): - - def test_bool_basic(self): - m = Metadata.from_row(lift([['bool', 'yes', 'no']])) - self.assertEqual(m.bool, ('yes', 'no')) - - def test_bool_true_false(self): - m = Metadata.from_row(lift([['bool', 'true', 'false']])) - self.assertEqual(m.bool, ('true', 'false')) - - def test_bool_wrong_arity(self): - with self.assertRaises(ValueError): - Metadata.from_row(lift([['bool', 'yes']])) - with self.assertRaises(ValueError): - Metadata.from_row(lift([['bool', 'a', 'b', 'c']])) - with self.assertRaises(ValueError): - Metadata.from_row(lift([['bool']])) - - -class TestTableForm(unittest.TestCase): - - def test_table_explicit_width(self): - m = Metadata.from_row(lift([['table', '5']])) - self.assertTrue(m.table) - self.assertEqual(m.width, 5) - - def test_table_infer(self): - m = Metadata.from_row(lift([['table']])) - self.assertTrue(m.table) - self.assertIsNone(m.width) - - def test_table_zero_width(self): - m = Metadata.from_row(lift([['table', '0']])) - self.assertTrue(m.table) - self.assertEqual(m.width, 0) - - def test_table_too_many_args(self): - with self.assertRaises(ValueError): - Metadata.from_row(lift([['table', '3', '4']])) - - def test_no_table_form(self): - m = Metadata.from_row(lift([['columns:', 'a', 'b']])) - self.assertFalse(m.table) - - -class TestUnknownForms(unittest.TestCase): - - def test_unknown_stashed(self): - m = Metadata.from_row(lift([['custom', 'arg1', 'arg2']])) - self.assertEqual(len(m.unknown), 1) - self.assertEqual(m.unknown[0].name, 'custom') - self.assertEqual(m.unknown[0].args, ['arg1', 'arg2']) - - def test_multiple_unknown(self): - m = Metadata.from_row(lift([['foo', 'a'], ['bar', 'b', 'c']])) - self.assertEqual(len(m.unknown), 2) - self.assertEqual(m.unknown[0].name, 'foo') - self.assertEqual(m.unknown[1].name, 'bar') - - def test_namespaced_form_raises(self): - with self.assertRaises(ValueError) as cm: - Metadata.from_row(lift([['foo/bar', 'x']])) - self.assertIn('reserved', str(cm.exception)) - - def test_namespaced_deep_raises(self): - with self.assertRaises(ValueError): - Metadata.from_row(lift([['a/b/c']])) - - -class TestAllFormsTogether(unittest.TestCase): - - def test_all_forms(self): - m = Metadata.from_row(lift([ - ['columns:', 'name', 'active', 'score'], - ['types:', 'str', 'bool', 'int'], - ['bool', 'Y', 'N'], - ['table', '3'], - ])) - self.assertEqual(m.columns, ['name', 'active', 'score']) - self.assertEqual(m.types, ['str', 'bool', 'int']) - self.assertEqual(m.bool, ('Y', 'N')) - self.assertTrue(m.table) - self.assertEqual(m.width, 3) - - def test_all_forms_with_unknown(self): - m = Metadata.from_row(lift([ - ['columns:', 'a'], ['types:', 'str'], ['bool', 't', 'f'], - ['table', '1'], ['version', '2'], - ])) - self.assertEqual(len(m.unknown), 1) - self.assertEqual(m.unknown[0].name, 'version') - - def test_partial_columns(self): - m = Metadata(columns=['a', 'b']) - self.assertEqual(_read(m, [['x', 'y', 'z']]), [['x', 'y', 'z']]) - - def test_partial_types(self): - m = Metadata(types=['int']) - self.assertEqual(_read(m, [['42', 'hello']]), [[42, 'hello']]) - - -# =================================================================== -# Arity agreement -# =================================================================== - -class TestArityAgreement(unittest.TestCase): - - def test_columns_types_mismatch(self): - with self.assertRaises(ValueError) as cm: - Metadata(columns=['a', 'b'], types=['str']) - self.assertIn('columns:', str(cm.exception)) - self.assertIn('types:', str(cm.exception)) - - def test_columns_table_mismatch(self): - with self.assertRaises(ValueError) as cm: - Metadata(columns=['a', 'b'], table=True, width=3) - self.assertIn('columns:', str(cm.exception)) - self.assertIn('table', str(cm.exception)) - - def test_types_table_mismatch(self): - with self.assertRaises(ValueError) as cm: - Metadata(types=['str', 'int'], table=True, width=3) - self.assertIn('types:', str(cm.exception)) - self.assertIn('table', str(cm.exception)) - - def test_all_three_agree(self): - m = Metadata(columns=['a', 'b'], types=['str', 'int'], table=True, width=2) - self.assertEqual(m.columns, ['a', 'b']) - - def test_columns_table_infer_no_error(self): - m = Metadata(columns=['a', 'b'], table=True) - self.assertEqual(m.columns, ['a', 'b']) - - -# =================================================================== -# Type conversion (via ENSVReader) -# =================================================================== - -class TestTypeConversionInt(unittest.TestCase): - - def test_positive(self): - self.assertEqual(_read(Metadata(types=['int']), [['42']]), [[42]]) - - def test_negative(self): - self.assertEqual(_read(Metadata(types=['int']), [['-7']]), [[-7]]) - - def test_zero(self): - self.assertEqual(_read(Metadata(types=['int']), [['0']]), [[0]]) - - def test_invalid(self): - with self.assertRaises(ValueError) as cm: - _read(Metadata(types=['int']), [['abc']]) - self.assertIn('row 0', str(cm.exception)) - self.assertIn('col 0', str(cm.exception)) - - -class TestTypeConversionFloat(unittest.TestCase): - - def test_positive(self): - self.assertEqual(_read(Metadata(types=['float']), [['3.14']]), [[3.14]]) - - def test_negative(self): - self.assertEqual(_read(Metadata(types=['float']), [['-2.5']]), [[-2.5]]) - - def test_zero(self): - self.assertEqual(_read(Metadata(types=['float']), [['0.0']]), [[0.0]]) - - def test_scientific(self): - self.assertEqual(_read(Metadata(types=['float']), [['1.5e10']]), [[1.5e10]]) - - def test_negative_scientific(self): - result = _read(Metadata(types=['float']), [['-3.2e-4']]) - self.assertAlmostEqual(result[0][0], -3.2e-4) - - def test_invalid(self): - with self.assertRaises(ValueError): - _read(Metadata(types=['float']), [['not_a_float']]) - - -class TestTypeConversionBool(unittest.TestCase): - - def test_true_sentinel(self): - m = Metadata(types=['bool'], bool_sentinels=('yes', 'no')) - self.assertEqual(_read(m, [['yes']]), [[True]]) - - def test_false_sentinel(self): - m = Metadata(types=['bool'], bool_sentinels=('yes', 'no')) - self.assertEqual(_read(m, [['no']]), [[False]]) - - def test_non_matching(self): - m = Metadata(types=['bool'], bool_sentinels=('yes', 'no')) - with self.assertRaises(ValueError) as cm: - _read(m, [['maybe']]) - self.assertIn('matches neither', str(cm.exception)) - - def test_case_sensitive(self): - m = Metadata(types=['bool'], bool_sentinels=('Yes', 'No')) - with self.assertRaises(ValueError): - _read(m, [['yes']]) - - def test_exact_match(self): - m = Metadata(types=['bool'], bool_sentinels=('Yes', 'No')) - with self.assertRaises(ValueError): - _read(m, [['Yes ']]) - - -class TestTypeConversionDate(unittest.TestCase): - - def test_valid(self): - m = Metadata(types=['date']) - self.assertEqual(_read(m, [['2024-03-15']]), [[datetime.date(2024, 3, 15)]]) - - def test_invalid(self): - with self.assertRaises(ValueError): - _read(Metadata(types=['date']), [['not-a-date']]) - - def test_invalid_format(self): - with self.assertRaises(ValueError): - _read(Metadata(types=['date']), [['03/15/2024']]) - - -class TestTypeConversionDatetime(unittest.TestCase): - - def test_without_timezone(self): - m = Metadata(types=['datetime']) - self.assertEqual( - _read(m, [['2024-03-15T10:30:00']]), - [[datetime.datetime(2024, 3, 15, 10, 30, 0)]]) - - def test_with_timezone(self): - m = Metadata(types=['datetime']) - result = _read(m, [['2024-03-15T10:30:00+05:00']]) - expected = datetime.datetime( - 2024, 3, 15, 10, 30, 0, - tzinfo=datetime.timezone(datetime.timedelta(hours=5))) - self.assertEqual(result[0][0], expected) - - def test_invalid(self): - with self.assertRaises(ValueError): - _read(Metadata(types=['datetime']), [['not-a-datetime']]) - - -class TestTypeConversionUUID(unittest.TestCase): - - def test_valid(self): - m = Metadata(types=['uuid']) - self.assertEqual( - _read(m, [['12345678-1234-5678-1234-567812345678']]), - [[uuid.UUID('12345678-1234-5678-1234-567812345678')]]) - - def test_invalid(self): - with self.assertRaises(ValueError): - _read(Metadata(types=['uuid']), [['not-a-uuid']]) - - -class TestTypeConversionStr(unittest.TestCase): - - def test_identity(self): - self.assertEqual(_read(Metadata(types=['str']), [['hello']]), [['hello']]) - - def test_empty_string(self): - self.assertEqual(_read(Metadata(types=['str']), [['']]), [['']]) - - -class TestTypeConversionUnknown(unittest.TestCase): - - def test_unknown_type_treated_as_str(self): - m = Metadata(types=['widget']) - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - result = _read(m, [['hello']]) - self.assertEqual(result, [['hello']]) - self.assertEqual(len(w), 1) - self.assertIn('unknown type', str(w[0].message)) - self.assertIn('widget', str(w[0].message)) - - -# =================================================================== -# Table validation (via ENSVReader) -# =================================================================== - -class TestTableValidation(unittest.TestCase): - - def test_explicit_width_correct(self): - m = Metadata(table=True, width=3) - self.assertEqual( - _read(m, [['a', 'b', 'c'], ['d', 'e', 'f']]), - [['a', 'b', 'c'], ['d', 'e', 'f']]) - - def test_explicit_width_incorrect(self): - with self.assertRaises(ValueError) as cm: - _read(Metadata(table=True, width=3), [['a', 'b']]) - self.assertIn('row 0', str(cm.exception)) - - def test_explicit_width_second_row_wrong(self): - with self.assertRaises(ValueError): - _read(Metadata(table=True, width=2), [['a', 'b'], ['c']]) - - def test_infer_from_first_row(self): - m = Metadata(table=True) - self.assertEqual( - _read(m, [['a', 'b'], ['c', 'd']]), - [['a', 'b'], ['c', 'd']]) - - def test_infer_reject_ragged(self): - with self.assertRaises(ValueError): - _read(Metadata(table=True), [['a', 'b'], ['c']]) - - def test_no_table_ragged_ok(self): - self.assertEqual( - _read(Metadata(), [['a', 'b'], ['c']]), - [['a', 'b'], ['c']]) - - def test_infer_empty_data(self): - self.assertEqual(_read(Metadata(table=True), []), []) - - -# =================================================================== -# Bool form interaction -# =================================================================== - -class TestBoolInteraction(unittest.TestCase): - - def test_types_bool_without_bool_form_errors(self): - with self.assertRaises(ValueError) as cm: - Metadata(types=['bool']) - self.assertIn('bool', str(cm.exception)) - - def test_bool_form_without_bool_type_ok(self): - m = Metadata(types=['str'], bool_sentinels=('y', 'n')) - self.assertIsNotNone(m.bool) - - def test_bool_sentinels_exact(self): - m = Metadata(types=['bool'], bool_sentinels=('1', '0')) - self.assertEqual(_read(m, [['1']]), [[True]]) - self.assertEqual(_read(m, [['0']]), [[False]]) - with self.assertRaises(ValueError): - _read(m, [['1 ']]) - - -# =================================================================== -# ENSVReader -# =================================================================== - -class TestENSVReader(unittest.TestCase): - - def test_basic(self): - r = ENSVReader(Metadata(types=['int', 'str'])) - self.assertEqual( - list(r.read([['1', 'a'], ['2', 'b']])), - [[1, 'a'], [2, 'b']]) - - def test_shared_schema_two_iterables(self): - r = ENSVReader(Metadata(types=['int'])) - self.assertEqual(list(r.read([['1'], ['2']])), [[1], [2]]) - self.assertEqual(list(r.read([['3'], ['4']])), [[3], [4]]) - - def test_replace_meta_between_reads(self): - r = ENSVReader(Metadata(types=['int'])) - self.assertEqual(list(r.read([['42']])), [[42]]) - r.meta = Metadata(types=['float']) - self.assertEqual(list(r.read([['3.14']])), [[3.14]]) - - def test_meta_property(self): - m = Metadata(types=['str']) - r = ENSVReader(m) - self.assertIs(r.meta, m) - - def test_width_resets_between_reads(self): - r = ENSVReader(Metadata(table=True)) - self.assertEqual(list(r.read([['a', 'b']])), [['a', 'b']]) - self.assertEqual(list(r.read([['x', 'y', 'z']])), [['x', 'y', 'z']]) - - def test_no_types_passthrough(self): - r = ENSVReader(Metadata()) - self.assertEqual(list(r.read([['a', 'b']])), [['a', 'b']]) - - -# =================================================================== -# peel -# =================================================================== - class TestPeel(unittest.TestCase): def test_peel_from_list(self): meta_row = lift([['types:', 'int']]) meta, tail = peel([meta_row, ['42']]) - self.assertEqual(meta.types, ['int']) + self.assertEqual(meta, [['types:', 'int']]) self.assertEqual(list(tail), [['42']]) def test_peel_from_generator(self): @@ -462,7 +23,7 @@ def gen(): meta, tail = peel(gen()) self.assertEqual(consumed, ['meta']) - self.assertEqual(meta.types, ['int']) + self.assertEqual(meta, [['types:', 'int']]) row1 = next(tail) self.assertEqual(consumed, ['meta', 'row1']) self.assertEqual(row1, ['42']) @@ -473,251 +34,33 @@ def test_peel_empty_raises(self): def test_peel_metadata_only(self): meta, tail = peel([lift([['columns:', 'a']])]) - self.assertEqual(meta.columns, ['a']) + self.assertEqual(meta, [['columns:', 'a']]) self.assertEqual(list(tail), []) - -# =================================================================== -# ensv.read (one-shot) -# =================================================================== - -class TestRead(unittest.TestCase): - - def test_one_shot(self): - meta_row = lift([['types:', 'int', 'str']]) - result = read([meta_row, ['1', 'a'], ['2', 'b']]) - self.assertEqual(list(result), [[1, 'a'], [2, 'b']]) - - def test_meta_accessible(self): - meta_row = lift([['columns:', 'x', 'y']]) - result = read([meta_row, ['1', '2']]) - self.assertEqual(result.meta.columns, ['x', 'y']) - self.assertEqual(list(result), [['1', '2']]) - - def test_usable_in_for_loop(self): - meta_row = lift([['types:', 'int']]) - rows = [] - for row in read([meta_row, ['1'], ['2']]): - rows.append(row) - self.assertEqual(rows, [[1], [2]]) - - def test_is_iterator(self): - result = read([lift([['columns:', 'a']]), ['x']]) - self.assertIs(iter(result), result) - - def test_empty_data(self): - result = read([lift([['types:', 'int']])]) - self.assertEqual(list(result), []) - - def test_empty_raises(self): - with self.assertRaises(ValueError): - read([]) - - -# =================================================================== -# Laziness -# =================================================================== - -class TestLaziness(unittest.TestCase): - - def test_reader_read_is_lazy(self): - consumed = [] - def gen(): - for i in range(5): - consumed.append(i) - yield [str(i)] - - r = ENSVReader(Metadata(types=['int'])) - it = r.read(gen()) - self.assertEqual(consumed, []) - self.assertEqual(next(it), [0]) - self.assertEqual(consumed, [0]) - self.assertEqual(next(it), [1]) - self.assertEqual(consumed, [0, 1]) - - def test_read_is_lazy(self): - consumed = [] - def gen(): - consumed.append('meta') - yield lift([['types:', 'int']]) - for i in range(3): - consumed.append(i) - yield [str(i)] - - result = read(gen()) - self.assertEqual(consumed, ['meta']) - self.assertEqual(next(result), [0]) - self.assertEqual(consumed, ['meta', 0]) - - def test_peel_consumes_exactly_one(self): - consumed = [] - def gen(): - for i in range(3): - consumed.append(i) - yield [str(i)] - - meta, tail = peel(gen()) - self.assertEqual(len(consumed), 1) - - -# =================================================================== -# Round-trip -# =================================================================== - -class TestRoundTrip(unittest.TestCase): - - def _roundtrip(self, meta): - self.assertEqual(Metadata.from_row(meta.to_row()), meta) - - def test_empty_metadata(self): - self._roundtrip(Metadata()) - - def test_columns_only(self): - self._roundtrip(Metadata(columns=['a', 'b', 'c'])) - - def test_types_only(self): - self._roundtrip(Metadata(types=['str', 'int', 'float'])) - - def test_bool_only(self): - self._roundtrip(Metadata(bool_sentinels=('yes', 'no'))) - - def test_table_explicit(self): - self._roundtrip(Metadata(table=True, width=5)) - - def test_table_infer(self): - self._roundtrip(Metadata(table=True)) - - def test_unknown_forms(self): - self._roundtrip(Metadata(unknown=[UnknownForm('custom', ['x', 'y'])])) - - def test_all_forms(self): - self._roundtrip(Metadata( - columns=['a', 'b'], types=['str', 'int'], - bool_sentinels=('t', 'f'), table=True, width=2, - unknown=[UnknownForm('extra', ['val'])], - )) - - def test_columns_with_special_chars(self): - self._roundtrip(Metadata(columns=['hello world', 'a,b', 'x\ny'])) - - def test_columns_with_empty_names(self): - self._roundtrip(Metadata(columns=['', 'b', ''])) - - def test_bool_sentinels_with_empty_string(self): - self._roundtrip(Metadata(bool_sentinels=('', 'no'))) - - def test_unknown_form_with_empty_args(self): - self._roundtrip(Metadata(unknown=[UnknownForm('nullable', ['', 'NULL'])])) - - def test_nsv_full_roundtrip(self): - meta = Metadata(columns=['name', 'score'], types=['str', 'int'], table=True, width=2) - data = [['Alice', '100'], ['Bob', '200']] - - ensv_seqseq = encode(meta, data) - nsv_string = nsv.dumps(ensv_seqseq) - recovered_seqseq = nsv.loads(nsv_string) - - result = read(recovered_seqseq) - self.assertEqual(result.meta, meta) - self.assertEqual(list(result), [['Alice', 100], ['Bob', 200]]) - - -# =================================================================== -# Sidecared metadata -# =================================================================== - -class TestSidecaredMetadata(unittest.TestCase): - - def test_sidecar_same_as_self_descriptive(self): - meta = Metadata( - columns=['id', 'name', 'active'], - types=['int', 'str', 'bool'], - bool_sentinels=('Y', 'N'), table=True, width=3, - ) - data = [['1', 'Alice', 'Y'], ['2', 'Bob', 'N']] - result = _read(meta, data) - self.assertEqual(result, [[1, 'Alice', True], [2, 'Bob', False]]) - - ensv_seqseq = [meta.to_row()] + data - result2 = list(read(ensv_seqseq)) - self.assertEqual(result, result2) - - def test_sidecar_types_only(self): - m = Metadata(types=['float', 'float']) - self.assertEqual( - _read(m, [['1.5', '2.5'], ['3.0', '4.0']]), - [[1.5, 2.5], [3.0, 4.0]]) - - def test_sidecar_table_validation(self): - with self.assertRaises(ValueError): - _read(Metadata(table=True, width=2), [['a', 'b', 'c']]) - - def test_sidecar_no_metadata(self): - self.assertEqual( - _read(Metadata(), [['a', 'b'], ['c']]), - [['a', 'b'], ['c']]) - - -# =================================================================== -# encode -# =================================================================== - -class TestEncode(unittest.TestCase): - - def test_encode_produces_valid_seqseq(self): - meta = Metadata(columns=['x']) - seqseq = encode(meta, [['1'], ['2']]) - self.assertEqual(len(seqseq), 3) - - def test_full_pipeline(self): - meta = Metadata(types=['str', 'int', 'float', 'date'], table=True, width=4) - data = [ - ['hello', '42', '3.14', '2024-01-01'], - ['world', '-1', '0.0', '2024-12-31'], - ] - ensv_seqseq = encode(meta, data) - nsv_string = nsv.dumps(ensv_seqseq) - recovered = nsv.loads(nsv_string) - result = read(recovered) - - self.assertEqual(result.meta, meta) - rows = list(result) - self.assertEqual(rows[0][0], 'hello') - self.assertEqual(rows[0][1], 42) - self.assertAlmostEqual(rows[0][2], 3.14) - self.assertEqual(rows[0][3], datetime.date(2024, 1, 1)) - - -# =================================================================== -# Edge cases -# =================================================================== - -class TestEdgeCases(unittest.TestCase): - - def test_empty_metadata_row(self): - meta = Metadata.from_row([]) - self.assertIsNone(meta.columns) - self.assertIsNone(meta.types) - self.assertIsNone(meta.bool) - self.assertFalse(meta.table) - self.assertEqual(meta.unknown, []) - - def test_multiple_type_conversions_per_row(self): - m = Metadata(types=['int', 'float', 'str'], bool_sentinels=('t', 'f')) - self.assertEqual(_read(m, [['10', '2.5', 'hello']]), [[10, 2.5, 'hello']]) - - def test_conversion_error_includes_indices(self): - m = Metadata(types=['str', 'int', 'str']) - with self.assertRaises(ValueError) as cm: - _read(m, [['ok', 'bad', 'ok']]) - msg = str(cm.exception) - self.assertIn('row 0', msg) - self.assertIn('col 1', msg) - - def test_bool_sentinels_can_be_empty_string(self): - m = Metadata(types=['bool'], bool_sentinels=('', 'n')) - self.assertEqual(_read(m, [['']]), [[True]]) - self.assertEqual(_read(m, [['n']]), [[False]]) + def test_peel_multiple_forms(self): + meta_row = lift([ + ['columns:', 'name', 'active'], + ['types:', 'str', 'bool'], + ['bool', 'yes', 'no'], + ]) + meta, tail = peel([meta_row, ['Alice', 'yes']]) + self.assertEqual(meta, [ + ['columns:', 'name', 'active'], + ['types:', 'str', 'bool'], + ['bool', 'yes', 'no'], + ]) + self.assertEqual(list(tail), [['Alice', 'yes']]) + + def test_peel_empty_metadata(self): + meta, tail = peel([[], ['data']]) + self.assertEqual(meta, [[]]) + self.assertEqual(list(tail), [['data']]) + + def test_peel_roundtrip_through_lift(self): + forms = [['columns:', '', 'b'], ['custom', 'x\ny', 'a\\b']] + meta_row = lift(forms) + meta, _ = peel([meta_row]) + self.assertEqual(meta, forms) if __name__ == '__main__': From 41e0686340d95da7bfae31ecca28036f9514f98e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 16:10:55 +0000 Subject: [PATCH 4/7] Replace peel with split/join: metadata as rows, not lifted split: find first empty row, return (meta seqseq, offset, data). join: inverse of split, concatenates meta + empty row + data. Aligns with spec's spill[Seq[String], []] for metadata separation. https://claude.ai/code/session_018keUYHFdMPKVUXkfE4LdCs --- nsv/ensv.py | 23 ++++--- tests/test_ensv.py | 152 +++++++++++++++++++++++++++------------------ 2 files changed, 109 insertions(+), 66 deletions(-) diff --git a/nsv/ensv.py b/nsv/ensv.py index b447961..f07f48c 100644 --- a/nsv/ensv.py +++ b/nsv/ensv.py @@ -47,10 +47,19 @@ def unlift(seq: Iterable[str]) -> List[List[str]]: return rows -def peel(rows): - it = iter(rows) - try: - meta_row = next(it) - except StopIteration: - raise ValueError("ENSV data must have at least a metadata row") - return unlift(meta_row), it +def split(seqseq): + """Split a seqseq at the first empty row. + + Returns (meta, offset, data) where meta is the rows before the + separator, offset is the index of the first data row, and data + is the rows after. If no empty row exists, all rows are meta. + """ + for i, row in enumerate(seqseq): + if not row: + return seqseq[:i], i + 1, seqseq[i + 1:] + return list(seqseq), len(seqseq), [] + + +def join(meta, data): + """Inverse of split. Concatenates meta + empty row + data.""" + return list(meta) + [[]] + list(data) diff --git a/tests/test_ensv.py b/tests/test_ensv.py index 7c5cd9f..744d3b9 100644 --- a/tests/test_ensv.py +++ b/tests/test_ensv.py @@ -1,67 +1,101 @@ import unittest -from nsv.ensv import lift, unlift, peel - - -class TestPeel(unittest.TestCase): - - def test_peel_from_list(self): - meta_row = lift([['types:', 'int']]) - meta, tail = peel([meta_row, ['42']]) - self.assertEqual(meta, [['types:', 'int']]) - self.assertEqual(list(tail), [['42']]) - - def test_peel_from_generator(self): - consumed = [] - def gen(): - consumed.append('meta') - yield lift([['types:', 'int']]) - consumed.append('row1') - yield ['42'] - consumed.append('row2') - yield ['99'] - - meta, tail = peel(gen()) - self.assertEqual(consumed, ['meta']) - self.assertEqual(meta, [['types:', 'int']]) - row1 = next(tail) - self.assertEqual(consumed, ['meta', 'row1']) - self.assertEqual(row1, ['42']) - - def test_peel_empty_raises(self): - with self.assertRaises(ValueError): - peel([]) - - def test_peel_metadata_only(self): - meta, tail = peel([lift([['columns:', 'a']])]) +import nsv +from nsv.ensv import lift, unlift, split, join + + +class TestSplit(unittest.TestCase): + + def test_basic(self): + seqseq = [['columns:', 'a', 'b'], [], ['1', '2'], ['3', '4']] + meta, offset, data = split(seqseq) + self.assertEqual(meta, [['columns:', 'a', 'b']]) + self.assertEqual(offset, 2) + self.assertEqual(data, [['1', '2'], ['3', '4']]) + + def test_multiple_meta_rows(self): + seqseq = [['columns:', 'x'], ['types:', 'int'], [], ['42']] + meta, offset, data = split(seqseq) + self.assertEqual(meta, [['columns:', 'x'], ['types:', 'int']]) + self.assertEqual(offset, 3) + self.assertEqual(data, [['42']]) + + def test_no_empty_row(self): + seqseq = [['a', 'b'], ['c', 'd']] + meta, offset, data = split(seqseq) + self.assertEqual(meta, [['a', 'b'], ['c', 'd']]) + self.assertEqual(offset, 2) + self.assertEqual(data, []) + + def test_empty_row_at_start(self): + seqseq = [[], ['a', 'b']] + meta, offset, data = split(seqseq) + self.assertEqual(meta, []) + self.assertEqual(offset, 1) + self.assertEqual(data, [['a', 'b']]) + + def test_only_empty_row(self): + meta, offset, data = split([[]]) + self.assertEqual(meta, []) + self.assertEqual(offset, 1) + self.assertEqual(data, []) + + def test_empty_seqseq(self): + meta, offset, data = split([]) + self.assertEqual(meta, []) + self.assertEqual(offset, 0) + self.assertEqual(data, []) + + def test_splits_at_first_empty_row_only(self): + seqseq = [['meta'], [], ['data1'], [], ['data2']] + meta, offset, data = split(seqseq) + self.assertEqual(meta, [['meta']]) + self.assertEqual(offset, 2) + self.assertEqual(data, [['data1'], [], ['data2']]) + + def test_no_data_after_separator(self): + seqseq = [['columns:', 'a'], []] + meta, offset, data = split(seqseq) self.assertEqual(meta, [['columns:', 'a']]) - self.assertEqual(list(tail), []) - - def test_peel_multiple_forms(self): - meta_row = lift([ - ['columns:', 'name', 'active'], - ['types:', 'str', 'bool'], - ['bool', 'yes', 'no'], - ]) - meta, tail = peel([meta_row, ['Alice', 'yes']]) - self.assertEqual(meta, [ - ['columns:', 'name', 'active'], - ['types:', 'str', 'bool'], - ['bool', 'yes', 'no'], - ]) - self.assertEqual(list(tail), [['Alice', 'yes']]) - - def test_peel_empty_metadata(self): - meta, tail = peel([[], ['data']]) - self.assertEqual(meta, [[]]) - self.assertEqual(list(tail), [['data']]) - - def test_peel_roundtrip_through_lift(self): - forms = [['columns:', '', 'b'], ['custom', 'x\ny', 'a\\b']] - meta_row = lift(forms) - meta, _ = peel([meta_row]) + self.assertEqual(offset, 2) + self.assertEqual(data, []) + + def test_meta_preserved_as_seqseq(self): + forms = [['columns:', 'name', 'score'], ['types:', 'str', 'int']] + seqseq = forms + [[]] + [['Alice', '100']] + meta, _, _ = split(seqseq) self.assertEqual(meta, forms) + def test_nsv_roundtrip(self): + original = [['columns:', 'x'], [], ['hello']] + text = nsv.dumps(original) + recovered = nsv.loads(text) + meta, offset, data = split(recovered) + self.assertEqual(meta, [['columns:', 'x']]) + self.assertEqual(data, [['hello']]) + + +class TestJoin(unittest.TestCase): + + def test_basic(self): + result = join([['columns:', 'a']], [['1'], ['2']]) + self.assertEqual(result, [['columns:', 'a'], [], ['1'], ['2']]) + + def test_empty_meta(self): + result = join([], [['a']]) + self.assertEqual(result, [[], ['a']]) + + def test_empty_data(self): + result = join([['columns:', 'x']], []) + self.assertEqual(result, [['columns:', 'x'], []]) + + def test_roundtrip(self): + meta = [['columns:', 'a', 'b'], ['types:', 'str', 'int']] + data = [['x', '1'], ['y', '2']] + m, _, d = split(join(meta, data)) + self.assertEqual(m, meta) + self.assertEqual(d, data) + if __name__ == '__main__': unittest.main() From 2b4644fe0052d6020795e99835c976e9878aad42 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 16:13:51 +0000 Subject: [PATCH 5/7] ENSV Reader/Writer mirroring NSV's streaming interface Reader: consumes meta rows until empty row separator, exposes .meta, then yields data rows on iteration. Writer: write_meta() writes forms + empty row, then write_row()/write_rows() for data, delegating to NSV Writer. https://claude.ai/code/session_018keUYHFdMPKVUXkfE4LdCs --- nsv/ensv.py | 70 ++++++++++++----- tests/test_ensv.py | 183 ++++++++++++++++++++++++--------------------- 2 files changed, 151 insertions(+), 102 deletions(-) diff --git a/nsv/ensv.py b/nsv/ensv.py index f07f48c..0d49a31 100644 --- a/nsv/ensv.py +++ b/nsv/ensv.py @@ -2,8 +2,8 @@ from typing import List, Iterable -from .reader import Reader -from .writer import Writer +from .reader import Reader as NSVReader +from .writer import Writer as NSVWriter def lift(seqseq: Iterable[Iterable[str]]) -> List[str]: @@ -20,7 +20,7 @@ def lift(seqseq: Iterable[Iterable[str]]) -> List[str]: if not first: result.append('') for cell in row: - result.append(Writer.escape(cell)) + result.append(NSVWriter.escape(cell)) first = False return result @@ -39,7 +39,7 @@ def unlift(seq: Iterable[str]) -> List[List[str]]: row = [] for element in seq: if element != '': - row.append(Reader.unescape(element)) + row.append(NSVReader.unescape(element)) else: rows.append(row) row = [] @@ -47,19 +47,55 @@ def unlift(seq: Iterable[str]) -> List[List[str]]: return rows -def split(seqseq): - """Split a seqseq at the first empty row. +class Reader: + def __init__(self, file_obj): + self._file = file_obj + self.meta = [] + self._read_meta() - Returns (meta, offset, data) where meta is the rows before the - separator, offset is the index of the first data row, and data - is the rows after. If no empty row exists, all rows are meta. - """ - for i, row in enumerate(seqseq): - if not row: - return seqseq[:i], i + 1, seqseq[i + 1:] - return list(seqseq), len(seqseq), [] + def _read_meta(self): + row = [] + for line in self._file: + if line == '\n': + if not row: + break + self.meta.append(row) + row = [] + else: + if line[-1] == '\n': + line = line[:-1] + row.append(NSVReader.unescape(line)) + else: + if row: + self.meta.append(row) + + def __iter__(self): + return self + + def __next__(self): + acc = [] + for line in self._file: + if line == '\n': + return acc + if line[-1] == '\n': + line = line[:-1] + acc.append(NSVReader.unescape(line)) + if acc: + return acc + raise StopIteration + + +class Writer: + def __init__(self, file_obj): + self._inner = NSVWriter(file_obj) + + def write_meta(self, meta): + for row in meta: + self._inner.write_row(row) + self._inner.write_row([]) + def write_row(self, row): + self._inner.write_row(row) -def join(meta, data): - """Inverse of split. Concatenates meta + empty row + data.""" - return list(meta) + [[]] + list(data) + def write_rows(self, rows): + self._inner.write_rows(rows) diff --git a/tests/test_ensv.py b/tests/test_ensv.py index 744d3b9..ac0f511 100644 --- a/tests/test_ensv.py +++ b/tests/test_ensv.py @@ -1,100 +1,113 @@ +import io import unittest -import nsv -from nsv.ensv import lift, unlift, split, join +from nsv.ensv import Reader, Writer -class TestSplit(unittest.TestCase): +class TestReader(unittest.TestCase): - def test_basic(self): - seqseq = [['columns:', 'a', 'b'], [], ['1', '2'], ['3', '4']] - meta, offset, data = split(seqseq) - self.assertEqual(meta, [['columns:', 'a', 'b']]) - self.assertEqual(offset, 2) - self.assertEqual(data, [['1', '2'], ['3', '4']]) + def _make(self, text): + return Reader(io.StringIO(text)) - def test_multiple_meta_rows(self): - seqseq = [['columns:', 'x'], ['types:', 'int'], [], ['42']] - meta, offset, data = split(seqseq) - self.assertEqual(meta, [['columns:', 'x'], ['types:', 'int']]) - self.assertEqual(offset, 3) - self.assertEqual(data, [['42']]) - - def test_no_empty_row(self): - seqseq = [['a', 'b'], ['c', 'd']] - meta, offset, data = split(seqseq) - self.assertEqual(meta, [['a', 'b'], ['c', 'd']]) - self.assertEqual(offset, 2) - self.assertEqual(data, []) - - def test_empty_row_at_start(self): - seqseq = [[], ['a', 'b']] - meta, offset, data = split(seqseq) - self.assertEqual(meta, []) - self.assertEqual(offset, 1) - self.assertEqual(data, [['a', 'b']]) - - def test_only_empty_row(self): - meta, offset, data = split([[]]) - self.assertEqual(meta, []) - self.assertEqual(offset, 1) - self.assertEqual(data, []) - - def test_empty_seqseq(self): - meta, offset, data = split([]) - self.assertEqual(meta, []) - self.assertEqual(offset, 0) - self.assertEqual(data, []) - - def test_splits_at_first_empty_row_only(self): - seqseq = [['meta'], [], ['data1'], [], ['data2']] - meta, offset, data = split(seqseq) - self.assertEqual(meta, [['meta']]) - self.assertEqual(offset, 2) - self.assertEqual(data, [['data1'], [], ['data2']]) - - def test_no_data_after_separator(self): - seqseq = [['columns:', 'a'], []] - meta, offset, data = split(seqseq) - self.assertEqual(meta, [['columns:', 'a']]) - self.assertEqual(offset, 2) - self.assertEqual(data, []) - - def test_meta_preserved_as_seqseq(self): - forms = [['columns:', 'name', 'score'], ['types:', 'str', 'int']] - seqseq = forms + [[]] + [['Alice', '100']] - meta, _, _ = split(seqseq) - self.assertEqual(meta, forms) - - def test_nsv_roundtrip(self): - original = [['columns:', 'x'], [], ['hello']] - text = nsv.dumps(original) - recovered = nsv.loads(text) - meta, offset, data = split(recovered) - self.assertEqual(meta, [['columns:', 'x']]) - self.assertEqual(data, [['hello']]) - - -class TestJoin(unittest.TestCase): - - def test_basic(self): - result = join([['columns:', 'a']], [['1'], ['2']]) - self.assertEqual(result, [['columns:', 'a'], [], ['1'], ['2']]) + def test_meta_and_data(self): + # meta: [columns: a b] [types: str int], separator, data: [Alice 100] [Bob 200] + r = self._make('columns:\na\nb\n\ntypes:\nstr\nint\n\n\nAlice\n100\n\nBob\n200\n\n') + self.assertEqual(r.meta, [['columns:', 'a', 'b'], ['types:', 'str', 'int']]) + self.assertEqual(list(r), [['Alice', '100'], ['Bob', '200']]) + + def test_single_meta_row(self): + r = self._make('columns:\nx\n\n\nhello\n\n') + self.assertEqual(r.meta, [['columns:', 'x']]) + self.assertEqual(list(r), [['hello']]) + + def test_no_data(self): + r = self._make('columns:\na\n\n\n') + self.assertEqual(r.meta, [['columns:', 'a']]) + self.assertEqual(list(r), []) def test_empty_meta(self): - result = join([], [['a']]) - self.assertEqual(result, [[], ['a']]) + # empty row right away = no meta forms + r = self._make('\nhello\n\n') + self.assertEqual(r.meta, []) + self.assertEqual(list(r), [['hello']]) + + def test_meta_preserved_for_later(self): + r = self._make('columns:\nname\n\ntypes:\nstr\n\n\nAlice\n\n') + meta = r.meta + _ = list(r) + self.assertEqual(meta, [['columns:', 'name'], ['types:', 'str']]) + self.assertIs(r.meta, meta) + + def test_streaming(self): + r = self._make('columns:\nx\n\n\n1\n\n2\n\n3\n\n') + self.assertEqual(r.meta, [['columns:', 'x']]) + self.assertEqual(next(r), ['1']) + self.assertEqual(next(r), ['2']) + self.assertEqual(next(r), ['3']) + + def test_no_separator_all_meta(self): + # no empty row separator = everything is meta + r = self._make('a\nb\n\nc\nd\n\n') + self.assertEqual(r.meta, [['a', 'b'], ['c', 'd']]) + self.assertEqual(list(r), []) + + +class TestWriter(unittest.TestCase): + + def _write(self, meta, data): + buf = io.StringIO() + w = Writer(buf) + w.write_meta(meta) + w.write_rows(data) + return buf.getvalue() + + def test_meta_and_data(self): + text = self._write( + [['columns:', 'a', 'b']], + [['1', '2'], ['3', '4']], + ) + r = Reader(io.StringIO(text)) + self.assertEqual(r.meta, [['columns:', 'a', 'b']]) + self.assertEqual(list(r), [['1', '2'], ['3', '4']]) + + def test_multiple_meta_rows(self): + text = self._write( + [['columns:', 'x'], ['types:', 'int']], + [['42']], + ) + r = Reader(io.StringIO(text)) + self.assertEqual(r.meta, [['columns:', 'x'], ['types:', 'int']]) + self.assertEqual(list(r), [['42']]) def test_empty_data(self): - result = join([['columns:', 'x']], []) - self.assertEqual(result, [['columns:', 'x'], []]) + text = self._write([['columns:', 'a']], []) + r = Reader(io.StringIO(text)) + self.assertEqual(r.meta, [['columns:', 'a']]) + self.assertEqual(list(r), []) + + def test_write_row_by_row(self): + buf = io.StringIO() + w = Writer(buf) + w.write_meta([['columns:', 'x']]) + w.write_row(['a']) + w.write_row(['b']) + r = Reader(io.StringIO(buf.getvalue())) + self.assertEqual(r.meta, [['columns:', 'x']]) + self.assertEqual(list(r), [['a'], ['b']]) + + +class TestRoundTrip(unittest.TestCase): def test_roundtrip(self): - meta = [['columns:', 'a', 'b'], ['types:', 'str', 'int']] - data = [['x', '1'], ['y', '2']] - m, _, d = split(join(meta, data)) - self.assertEqual(m, meta) - self.assertEqual(d, data) + meta = [['columns:', 'name', 'score'], ['types:', 'str', 'int']] + data = [['Alice', '100'], ['Bob', '200']] + buf = io.StringIO() + w = Writer(buf) + w.write_meta(meta) + w.write_rows(data) + r = Reader(io.StringIO(buf.getvalue())) + self.assertEqual(r.meta, meta) + self.assertEqual(list(r), data) if __name__ == '__main__': From e2413cf24d40f5f028aa2d3420098c660d34317e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 16:18:28 +0000 Subject: [PATCH 6/7] ENSV Reader/Writer: delegate to NSV, don't duplicate https://claude.ai/code/session_018keUYHFdMPKVUXkfE4LdCs --- nsv/ensv.py | 37 +++++++------------------------------ 1 file changed, 7 insertions(+), 30 deletions(-) diff --git a/nsv/ensv.py b/nsv/ensv.py index 0d49a31..e75292f 100644 --- a/nsv/ensv.py +++ b/nsv/ensv.py @@ -49,40 +49,18 @@ def unlift(seq: Iterable[str]) -> List[List[str]]: class Reader: def __init__(self, file_obj): - self._file = file_obj + self._inner = NSVReader(file_obj) self.meta = [] - self._read_meta() - - def _read_meta(self): - row = [] - for line in self._file: - if line == '\n': - if not row: - break - self.meta.append(row) - row = [] - else: - if line[-1] == '\n': - line = line[:-1] - row.append(NSVReader.unescape(line)) - else: - if row: - self.meta.append(row) + for row in self._inner: + if not row: + break + self.meta.append(row) def __iter__(self): return self def __next__(self): - acc = [] - for line in self._file: - if line == '\n': - return acc - if line[-1] == '\n': - line = line[:-1] - acc.append(NSVReader.unescape(line)) - if acc: - return acc - raise StopIteration + return next(self._inner) class Writer: @@ -90,8 +68,7 @@ def __init__(self, file_obj): self._inner = NSVWriter(file_obj) def write_meta(self, meta): - for row in meta: - self._inner.write_row(row) + self._inner.write_rows(meta) self._inner.write_row([]) def write_row(self, row): From da57907cc7dafd0e1bf608b41bc01e84fd0b9571 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 18:53:28 +0000 Subject: [PATCH 7/7] Remove unnecessary ensv import from __init__.py https://claude.ai/code/session_018keUYHFdMPKVUXkfE4LdCs --- nsv/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/nsv/__init__.py b/nsv/__init__.py index 249f167..46d7124 100644 --- a/nsv/__init__.py +++ b/nsv/__init__.py @@ -1,7 +1,6 @@ from .core import load, loads, dump, dumps from .reader import Reader from .writer import Writer -from . import ensv __version__ = "0.2.2"