Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions nsv/ensv.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from typing import List, Iterable

from .reader import Reader
from .writer import Writer


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.

Escapes every cell and chains them with empty-string separators
between rows (separator semantics, not 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]]:
"""
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
145 changes: 145 additions & 0 deletions tests/test_lift.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import unittest

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):

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_escaping_backslash(self):
self.assertEqual(['a\\\\b'], lift([['a\\b']]))

def test_escaping_newline(self):
self.assertEqual(['a\\nb'], lift([['a\nb']]))

def test_escaping_empty_cell(self):
self.assertEqual(['\\'], lift([['']]))

def test_escaping_multiple_specials(self):
result = lift([['a\\b', 'c\nd'], ['', 'e']])
self.assertEqual(['a\\\\b', 'c\\nd', '', '\\', 'e'], result)


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_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_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_trailing_separator(self):
self.assertEqual([['a'], []], unlift(['a', '']))


class TestLiftUnliftRoundtrip(unittest.TestCase):
"""unlift(lift(x)) == x for all non-empty seqseqs."""

def test_roundtrip(self):
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):
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."""

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)))


class TestLiftDecomposition(unittest.TestCase):
"""Verify equivalence: lift = init . spill[String, ''] . map(map(escape))."""

def test_equivalence(self):
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))


if __name__ == '__main__':
unittest.main()