From 0e5bc8a4b7a4a8e1b63bc0fe770e4df794980c44 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Feb 2026 17:07:47 +0000 Subject: [PATCH 1/3] Implement ENSV lift/unlift operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add nsv/ensv.py with lift and unlift functions as defined in the ENSV spec. lift collapses a seqseq into a single row using separator semantics (init ∘ spill[String, ''] ∘ map(map(escape))), and unlift recovers the original structure by unescaping and splitting on empty strings. Includes 31 tests covering escaping, empty rows, ragged data, and bidirectional roundtrip properties. https://claude.ai/code/session_01PuxiBkVsjVDzcSopK3kNRg --- nsv/__init__.py | 1 + nsv/ensv.py | 47 +++++++++++ tests/test_ensv.py | 194 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 242 insertions(+) create mode 100644 nsv/ensv.py create mode 100644 tests/test_ensv.py diff --git a/nsv/__init__.py b/nsv/__init__.py index 46d7124..331f475 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 .ensv import lift, unlift __version__ = "0.2.2" diff --git a/nsv/ensv.py b/nsv/ensv.py new file mode 100644 index 0000000..b89be3c --- /dev/null +++ b/nsv/ensv.py @@ -0,0 +1,47 @@ +from typing import List, Iterable + +from .reader import Reader +from .writer import Writer +from .util import spill + + +def lift(seqseq: Iterable[Iterable[str]]) -> List[str]: + """ + Lift a seqseq into a single row (sequence of strings), removing one + dimension without losing structural information. + + lift = init ∘ spill[String, ''] ∘ map(map(escape)) + + The result uses separator semantics: empty strings delimit row boundaries, + and the final terminator is discarded (init) to preserve line numbers. + + This makes [] irrepresentable; a non-empty seqseq is required. + """ + rows = list(seqseq) + if not rows: + raise ValueError("Cannot lift an empty seqseq: [] is irrepresentable") + escaped = [[Writer.escape(cell) for cell in row] for row in rows] + spilled = spill(escaped, '') + return spilled[:-1] # init: discard trailing terminator + + +def unlift(seq: Iterable[str]) -> List[List[str]]: + """ + Unlift a sequence of strings back into a seqseq, recovering the + dimension collapsed by lift. + + 1. If the current element is non-empty, unescape it and append to the + current row + 2. If the current element is empty, terminate the current row + 3. At end of input, terminate the current row + """ + rows = [] + row = [] + for element in seq: + if element != '': + row.append(Reader.unescape(element)) + else: + rows.append(row) + row = [] + rows.append(row) + return rows diff --git a/tests/test_ensv.py b/tests/test_ensv.py new file mode 100644 index 0000000..c434eff --- /dev/null +++ b/tests/test_ensv.py @@ -0,0 +1,194 @@ +import unittest + +from nsv.ensv import lift, unlift + + +class TestLift(unittest.TestCase): + + def test_single_row_single_cell(self): + self.assertEqual(['a'], lift([['a']])) + + def test_single_row_multiple_cells(self): + self.assertEqual(['a', 'b', 'c'], lift([['a', 'b', 'c']])) + + def test_multiple_rows(self): + self.assertEqual( + ['a', 'b', '', 'c', 'd'], + lift([['a', 'b'], ['c', 'd']]), + ) + + def test_ragged_rows(self): + self.assertEqual( + ['a', 'b', '', 'c'], + lift([['a', 'b'], ['c']]), + ) + + def test_single_empty_row(self): + """[[]] lifts to [] — the single empty row becomes an empty sequence.""" + self.assertEqual([], lift([[]])) + + def test_two_empty_rows(self): + self.assertEqual([''], lift([[], []])) + + def test_three_empty_rows(self): + self.assertEqual(['', ''], lift([[], [], []])) + + def test_empty_row_between_data(self): + self.assertEqual( + ['a', '', '', 'b'], + lift([['a'], [], ['b']]), + ) + + def test_empty_seqseq_raises(self): + with self.assertRaises(ValueError): + lift([]) + + def test_escaping_backslash(self): + """Cells containing backslashes are escaped during lift.""" + self.assertEqual(['a\\\\b'], lift([['a\\b']])) + + def test_escaping_newline(self): + """Cells containing newlines are escaped during lift.""" + self.assertEqual(['a\\nb'], lift([['a\nb']])) + + def test_escaping_empty_cell(self): + """Empty cells become the escape sentinel '\\'.""" + self.assertEqual(['\\'], lift([['']])) + + def test_escaping_multiple_specials(self): + result = lift([['a\\b', 'c\nd'], ['', 'e']]) + self.assertEqual(['a\\\\b', 'c\\nd', '', '\\', 'e'], result) + + def test_row_ending_with_empty_row(self): + self.assertEqual( + ['a', ''], + lift([['a'], []]), + ) + + def test_single_cell_rows(self): + self.assertEqual( + ['x', '', 'y', '', 'z'], + lift([['x'], ['y'], ['z']]), + ) + + +class TestUnlift(unittest.TestCase): + + def test_single_element(self): + self.assertEqual([['a']], unlift(['a'])) + + def test_multiple_elements_single_row(self): + self.assertEqual([['a', 'b', 'c']], unlift(['a', 'b', 'c'])) + + def test_multiple_rows(self): + self.assertEqual( + [['a', 'b'], ['c', 'd']], + unlift(['a', 'b', '', 'c', 'd']), + ) + + def test_ragged_rows(self): + self.assertEqual( + [['a', 'b'], ['c']], + unlift(['a', 'b', '', 'c']), + ) + + def test_empty_input(self): + """Empty input unlifts to [[]] — terminates the (empty) current row.""" + self.assertEqual([[]], unlift([])) + + def test_single_separator(self): + """A single empty string produces two empty rows.""" + self.assertEqual([[], []], unlift([''])) + + def test_two_separators(self): + self.assertEqual([[], [], []], unlift(['', ''])) + + def test_empty_row_between_data(self): + self.assertEqual( + [['a'], [], ['b']], + unlift(['a', '', '', 'b']), + ) + + def test_unescaping_backslash(self): + self.assertEqual([['a\\b']], unlift(['a\\\\b'])) + + def test_unescaping_newline(self): + self.assertEqual([['a\nb']], unlift(['a\\nb'])) + + def test_unescaping_empty_cell(self): + self.assertEqual([['']], unlift(['\\'])) + + def test_unescaping_multiple_specials(self): + self.assertEqual( + [['a\\b', 'c\nd'], ['', 'e']], + unlift(['a\\\\b', 'c\\nd', '', '\\', 'e']), + ) + + def test_trailing_separator(self): + self.assertEqual( + [['a'], []], + unlift(['a', '']), + ) + + +class TestLiftUnliftRoundtrip(unittest.TestCase): + """unlift(lift(x)) == x for all non-empty seqseqs.""" + + CASES = [ + [['a']], + [['a', 'b']], + [['a', 'b'], ['c', 'd']], + [['a', 'b'], ['c']], + [['a'], ['b', 'c']], + [[]], + [[], []], + [[], [], []], + [['a'], []], + [[], ['a']], + [['a'], [], ['b']], + [['']], + [['', '']], + [['a\\b']], + [['a\nb']], + [['a\\b', 'c\nd'], ['', 'e']], + [['x'], ['y'], ['z']], + [['\\n', '\\\n', '\\\\n'], ['\\\\', '\n\n']], + ] + + def test_roundtrip(self): + for seqseq in self.CASES: + with self.subTest(seqseq=seqseq): + self.assertEqual(seqseq, unlift(lift(seqseq))) + + def test_lift_produces_no_newlines(self): + """Lifted cells must not contain newlines (escape invariant).""" + for seqseq in self.CASES: + with self.subTest(seqseq=seqseq): + for cell in lift(seqseq): + self.assertNotIn('\n', cell) + + +class TestUnliftLiftRoundtrip(unittest.TestCase): + """lift(unlift(y)) == y for valid lifted sequences (non-empty unlift result).""" + + CASES = [ + ['a'], + ['a', 'b'], + ['a', 'b', '', 'c'], + ['a', '', '', 'b'], + [''], + ['', ''], + ['a', ''], + ['\\'], + ['a\\\\b', 'c\\nd'], + ['a\\\\b', 'c\\nd', '', '\\', 'e'], + ] + + def test_roundtrip(self): + for seq in self.CASES: + with self.subTest(seq=seq): + self.assertEqual(seq, lift(unlift(seq))) + + +if __name__ == '__main__': + unittest.main() From 8e73462a509dd2fb18d9475f6bcfb881527036f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 17:33:19 +0000 Subject: [PATCH 2/3] Rework lift: direct iteration, no slicing, no validation Rewrite lift to use separator semantics directly instead of going through spill+init. Remove ValueError for empty input (caller's responsibility). Remove lift/unlift from top-level namespace. Add decomposition equivalence test. https://claude.ai/code/session_01PuxiBkVsjVDzcSopK3kNRg --- nsv/__init__.py | 1 - nsv/ensv.py | 24 +++++++++++------------- tests/test_ensv.py | 27 +++++++++++++++++++++++---- 3 files changed, 34 insertions(+), 18 deletions(-) diff --git a/nsv/__init__.py b/nsv/__init__.py index 331f475..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 .ensv import lift, unlift __version__ = "0.2.2" diff --git a/nsv/ensv.py b/nsv/ensv.py index b89be3c..37704b1 100644 --- a/nsv/ensv.py +++ b/nsv/ensv.py @@ -2,7 +2,6 @@ from .reader import Reader from .writer import Writer -from .util import spill def lift(seqseq: Iterable[Iterable[str]]) -> List[str]: @@ -10,19 +9,18 @@ def lift(seqseq: Iterable[Iterable[str]]) -> List[str]: Lift a seqseq into a single row (sequence of strings), removing one dimension without losing structural information. - lift = init ∘ spill[String, ''] ∘ map(map(escape)) - - The result uses separator semantics: empty strings delimit row boundaries, - and the final terminator is discarded (init) to preserve line numbers. - - This makes [] irrepresentable; a non-empty seqseq is required. + Escapes every cell and chains them with empty-string separators + between rows (separator semantics, not terminator). """ - rows = list(seqseq) - if not rows: - raise ValueError("Cannot lift an empty seqseq: [] is irrepresentable") - escaped = [[Writer.escape(cell) for cell in row] for row in rows] - spilled = spill(escaped, '') - return spilled[:-1] # init: discard trailing terminator + result = [] + first = True + for row in seqseq: + if not first: + result.append('') + for cell in row: + result.append(Writer.escape(cell)) + first = False + return result def unlift(seq: Iterable[str]) -> List[List[str]]: diff --git a/tests/test_ensv.py b/tests/test_ensv.py index c434eff..da7db78 100644 --- a/tests/test_ensv.py +++ b/tests/test_ensv.py @@ -1,6 +1,7 @@ import unittest from nsv.ensv import lift, unlift +from nsv.util import escape_seqseq, spill class TestLift(unittest.TestCase): @@ -39,10 +40,6 @@ def test_empty_row_between_data(self): lift([['a'], [], ['b']]), ) - def test_empty_seqseq_raises(self): - with self.assertRaises(ValueError): - lift([]) - def test_escaping_backslash(self): """Cells containing backslashes are escaped during lift.""" self.assertEqual(['a\\\\b'], lift([['a\\b']])) @@ -190,5 +187,27 @@ def test_roundtrip(self): self.assertEqual(seq, lift(unlift(seq))) +class TestLiftDecomposition(unittest.TestCase): + """Verify equivalence with the decomposition: lift = init ∘ spill[String, ''] ∘ map(map(escape)).""" + + CASES = [ + [['a']], + [['a', 'b'], ['c', 'd']], + [['a', 'b'], ['c']], + [[]], + [[], []], + [['a'], [], ['b']], + [['']], + [['a\\b', 'c\nd'], ['', 'e']], + [['\\n', '\\\n', '\\\\n'], ['\\\\', '\n\n']], + ] + + def test_equivalence(self): + for seqseq in self.CASES: + with self.subTest(seqseq=seqseq): + via_decomposition = spill(escape_seqseq(seqseq), '')[:-1] + self.assertEqual(via_decomposition, lift(seqseq)) + + if __name__ == '__main__': unittest.main() From cf039519c9d2c61abfdc31764ae997398141f7e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 12:22:09 +0000 Subject: [PATCH 3/3] Rename test_ensv to test_lift, reuse SAMPLES_DATA Use existing SAMPLES_DATA from test_utils for roundtrip and decomposition equivalence tests instead of maintaining a separate set of cases. Trim redundant unit tests that are covered by samples. https://claude.ai/code/session_01PuxiBkVsjVDzcSopK3kNRg --- tests/{test_ensv.py => test_lift.py} | 96 ++++------------------------ 1 file changed, 14 insertions(+), 82 deletions(-) rename tests/{test_ensv.py => test_lift.py} (60%) diff --git a/tests/test_ensv.py b/tests/test_lift.py similarity index 60% rename from tests/test_ensv.py rename to tests/test_lift.py index da7db78..19776e9 100644 --- a/tests/test_ensv.py +++ b/tests/test_lift.py @@ -2,6 +2,11 @@ from nsv.ensv import lift, unlift from nsv.util import escape_seqseq, spill +from test_utils import SAMPLES_DATA + + +# All non-empty samples — [] is irrepresentable by lift. +LIFT_SAMPLES = {k: v for k, v in SAMPLES_DATA.items() if v} class TestLift(unittest.TestCase): @@ -41,33 +46,18 @@ def test_empty_row_between_data(self): ) def test_escaping_backslash(self): - """Cells containing backslashes are escaped during lift.""" self.assertEqual(['a\\\\b'], lift([['a\\b']])) def test_escaping_newline(self): - """Cells containing newlines are escaped during lift.""" self.assertEqual(['a\\nb'], lift([['a\nb']])) def test_escaping_empty_cell(self): - """Empty cells become the escape sentinel '\\'.""" self.assertEqual(['\\'], lift([['']])) def test_escaping_multiple_specials(self): result = lift([['a\\b', 'c\nd'], ['', 'e']]) self.assertEqual(['a\\\\b', 'c\\nd', '', '\\', 'e'], result) - def test_row_ending_with_empty_row(self): - self.assertEqual( - ['a', ''], - lift([['a'], []]), - ) - - def test_single_cell_rows(self): - self.assertEqual( - ['x', '', 'y', '', 'z'], - lift([['x'], ['y'], ['z']]), - ) - class TestUnlift(unittest.TestCase): @@ -83,12 +73,6 @@ def test_multiple_rows(self): unlift(['a', 'b', '', 'c', 'd']), ) - def test_ragged_rows(self): - self.assertEqual( - [['a', 'b'], ['c']], - unlift(['a', 'b', '', 'c']), - ) - def test_empty_input(self): """Empty input unlifts to [[]] — terminates the (empty) current row.""" self.assertEqual([[]], unlift([])) @@ -97,15 +81,6 @@ def test_single_separator(self): """A single empty string produces two empty rows.""" self.assertEqual([[], []], unlift([''])) - def test_two_separators(self): - self.assertEqual([[], [], []], unlift(['', ''])) - - def test_empty_row_between_data(self): - self.assertEqual( - [['a'], [], ['b']], - unlift(['a', '', '', 'b']), - ) - def test_unescaping_backslash(self): self.assertEqual([['a\\b']], unlift(['a\\\\b'])) @@ -115,58 +90,27 @@ def test_unescaping_newline(self): def test_unescaping_empty_cell(self): self.assertEqual([['']], unlift(['\\'])) - def test_unescaping_multiple_specials(self): - self.assertEqual( - [['a\\b', 'c\nd'], ['', 'e']], - unlift(['a\\\\b', 'c\\nd', '', '\\', 'e']), - ) - def test_trailing_separator(self): - self.assertEqual( - [['a'], []], - unlift(['a', '']), - ) + self.assertEqual([['a'], []], unlift(['a', ''])) class TestLiftUnliftRoundtrip(unittest.TestCase): """unlift(lift(x)) == x for all non-empty seqseqs.""" - CASES = [ - [['a']], - [['a', 'b']], - [['a', 'b'], ['c', 'd']], - [['a', 'b'], ['c']], - [['a'], ['b', 'c']], - [[]], - [[], []], - [[], [], []], - [['a'], []], - [[], ['a']], - [['a'], [], ['b']], - [['']], - [['', '']], - [['a\\b']], - [['a\nb']], - [['a\\b', 'c\nd'], ['', 'e']], - [['x'], ['y'], ['z']], - [['\\n', '\\\n', '\\\\n'], ['\\\\', '\n\n']], - ] - def test_roundtrip(self): - for seqseq in self.CASES: - with self.subTest(seqseq=seqseq): + for name, seqseq in LIFT_SAMPLES.items(): + with self.subTest(name=name): self.assertEqual(seqseq, unlift(lift(seqseq))) def test_lift_produces_no_newlines(self): - """Lifted cells must not contain newlines (escape invariant).""" - for seqseq in self.CASES: - with self.subTest(seqseq=seqseq): + for name, seqseq in LIFT_SAMPLES.items(): + with self.subTest(name=name): for cell in lift(seqseq): self.assertNotIn('\n', cell) class TestUnliftLiftRoundtrip(unittest.TestCase): - """lift(unlift(y)) == y for valid lifted sequences (non-empty unlift result).""" + """lift(unlift(y)) == y for valid lifted sequences.""" CASES = [ ['a'], @@ -188,23 +132,11 @@ def test_roundtrip(self): class TestLiftDecomposition(unittest.TestCase): - """Verify equivalence with the decomposition: lift = init ∘ spill[String, ''] ∘ map(map(escape)).""" - - CASES = [ - [['a']], - [['a', 'b'], ['c', 'd']], - [['a', 'b'], ['c']], - [[]], - [[], []], - [['a'], [], ['b']], - [['']], - [['a\\b', 'c\nd'], ['', 'e']], - [['\\n', '\\\n', '\\\\n'], ['\\\\', '\n\n']], - ] + """Verify equivalence: lift = init . spill[String, ''] . map(map(escape)).""" def test_equivalence(self): - for seqseq in self.CASES: - with self.subTest(seqseq=seqseq): + for name, seqseq in LIFT_SAMPLES.items(): + with self.subTest(name=name): via_decomposition = spill(escape_seqseq(seqseq), '')[:-1] self.assertEqual(via_decomposition, lift(seqseq))