From 19faa0a4f68d86368fc9af617674b9fca1a43463 Mon Sep 17 00:00:00 2001 From: "Oran S.Cohen" <83370164+oranshabatcohen@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:02:23 +0300 Subject: [PATCH] Add expanded record display to csvlook Implement --expanded for issue #1313, with regression tests and documentation. Prepared with OpenAI Codex assistance. Co-authored-by: Codex --- AUTHORS.rst | 1 + CHANGELOG.rst | 1 + csvkit/utilities/csvlook.py | 56 ++++++++++++- docs/scripts/csvlook.rst | 26 +++++- man/csvlook.1 | 16 +++- tests/test_utilities/test_csvlook.py | 117 +++++++++++++++++++++++++++ 6 files changed, 213 insertions(+), 4 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index f306722c6..8ef8eba43 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -116,3 +116,4 @@ The following individuals have contributed code to csvkit: * lamdevhs * Sai Asish Y * Peng-Yu Chen +* Oran S.Cohen diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5fdf4a1a3..3ba5f5ae5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,7 @@ Unreleased ---------- +- feat: :doc:`/scripts/csvlook` adds an :code:`--expanded` option to display records vertically, with one field per line. - feat: :doc:`/scripts/csvcut` adds an :code:`--ignore-unknown-columns` option to skip identifiers in :code:`-c/--columns` that do not match a column in the input. - feat: :doc:`/scripts/csvclean` adds a :code:`--remove-empty-columns` option to remove empty columns from standard output. - feat: :doc:`/scripts/in2csv` guesses the ``ndjson`` format for files with :code:`.ndjson`, :code:`.jsonl` and :code:`.jl` extensions. diff --git a/csvkit/utilities/csvlook.py b/csvkit/utilities/csvlook.py index 13ec786d0..459b5b3b4 100644 --- a/csvkit/utilities/csvlook.py +++ b/csvkit/utilities/csvlook.py @@ -1,7 +1,10 @@ #!/usr/bin/env python +import math + import agate -from agate import config +from agate import config, utils +from babel.numbers import format_decimal from csvkit.cli import CSVKitUtility @@ -10,6 +13,9 @@ class CSVLook(CSVKitUtility): description = 'Render a CSV file in the console as a Markdown-compatible, fixed-width table.' def add_arguments(self): + self.argparser.add_argument( + '--expanded', action='store_true', + help='Display each record vertically, with one field per line.') self.argparser.add_argument( '--max-rows', dest='max_rows', type=int, help='The maximum number of rows to display before truncating the data.') @@ -57,6 +63,10 @@ def main(self): **self.reader_kwargs, ) + if self.args.expanded: + self.print_expanded(table, **kwargs) + return + table.print_table( output=self.output_file, max_rows=self.args.max_rows, @@ -65,6 +75,50 @@ def main(self): **kwargs, ) + def print_expanded(self, table, max_precision=3): + """Display records vertically, retaining csvlook's numeric formatting.""" + columns = table.columns[:self.args.max_columns] + ellipsis = config.get_option('ellipsis_chars') + truncation = config.get_option('text_truncation_chars') + separator = config.get_option('vertical_line_char') + locale = config.get_option('default_locale') + + def format_text(value): + text = str(value).replace('\r\n', '\n').replace('\r', '\n').replace('\n', '↵').replace('\t', '⇥') + width = self.args.max_column_width + if width is not None and len(text) > width: + text = text[:max(0, width - len(truncation))] + truncation + return text + + names = [format_text(column.name) for column in columns] + columns_truncated = len(columns) < len(table.columns) + if columns_truncated: + names.append(ellipsis) + name_width = max((len(name) for name in names), default=0) + + # Determine precision per source column, just as print_table does. + formatters = [] + for column in columns: + if isinstance(column.data_type, agate.Number): + places = utils.max_precision(column) + formatters.append(utils.make_number_formatter(min(places, max_precision), places > max_precision)) + else: + formatters.append(None) + + for record_number, row in enumerate(table.rows, 1): + self.output_file.write(f'-[ RECORD {record_number} ]-\n') + for index, (name, formatter) in enumerate(zip(names, formatters)): + value = row[index] + if value is None: + text = '' + elif formatter is not None and not math.isinf(value): + text = format_text(format_decimal(value, format=formatter, locale=locale)) + else: + text = format_text(value) + self.output_file.write(f'{name.ljust(name_width)} {separator} {text}\n') + if columns_truncated: + self.output_file.write(f'{ellipsis.ljust(name_width)} {separator} {ellipsis}\n') + def launch_new_instance(): utility = CSVLook() diff --git a/docs/scripts/csvlook.rst b/docs/scripts/csvlook.rst index 4b04d7360..2c7cc3052 100644 --- a/docs/scripts/csvlook.rst +++ b/docs/scripts/csvlook.rst @@ -14,7 +14,7 @@ Renders a CSV to the command line in a Markdown-compatible, fixed-width format: [-S] [--blanks] [--null-value NULL_VALUES [NULL_VALUES ...]] [--date-format DATE_FORMAT] [--datetime-format DATETIME_FORMAT] [-H] [-K SKIP_LINES] [-v] [-l] [--zero] [-V] - [--max-rows MAX_ROWS] [--max-columns MAX_COLUMNS] + [--expanded] [--max-rows MAX_ROWS] [--max-columns MAX_COLUMNS] [--max-column-width MAX_COLUMN_WIDTH] [--max-precision MAX_PRECISION] [--no-number-ellipsis] [-y SNIFF_LIMIT] [-I] @@ -28,6 +28,7 @@ Renders a CSV to the command line in a Markdown-compatible, fixed-width format: optional arguments: -h, --help show this help message and exit + --expanded Display each record vertically, with one field per line. --max-rows MAX_ROWS The maximum number of rows to display before truncating the data. --max-columns MAX_COLUMNS @@ -48,7 +49,7 @@ Renders a CSV to the command line in a Markdown-compatible, fixed-width format: --datetime-format, --no-leading-zeroes) when parsing the input. -If a table is too wide to display properly try piping the output to ``less -S`` or truncating it using :doc:`csvcut`. +If a table is too wide to display properly, try ``--expanded`` to display each record vertically, piping the output to ``less -S``, or truncating it using :doc:`csvcut`. If the table is too long, try filtering it down with grep or piping the output to ``less``. @@ -67,6 +68,27 @@ Basic use: csvlook examples/testfixed_converted.csv +Display records vertically, with the column names on the left and values on the right: + +.. code-block:: bash + + csvlook --expanded --no-inference examples/dummy3.csv + +.. code-block:: none + + -[ RECORD 1 ]- + a | 1 + b | 2 + c | 3 + -[ RECORD 2 ]- + a | 1 + b | 4 + c | 5 + +The expanded view supports the existing input and display options. ``--max-rows`` limits the number of records, and ``--max-columns`` limits the fields within each record, adding ``...`` for omitted fields. ``--max-column-width`` truncates field names and values; the truncation marker can exceed very small widths. Numeric precision follows ``--max-precision`` and ``--no-number-ellipsis``. Missing values are blank. Line breaks and tabs in names and values appear as ``↵`` and ``⇥``. Empty input, a header without data, or ``--max-rows 0`` produces no expanded records. + +Expanded output is intended for reading in the terminal, rather than as a Markdown table or machine-readable CSV. + This tool is especially useful as a final operation when piping through other tools: .. code-block:: bash diff --git a/man/csvlook.1 b/man/csvlook.1 index 919f62788..9123479fc 100644 --- a/man/csvlook.1 +++ b/man/csvlook.1 @@ -43,7 +43,7 @@ usage: csvlook [\-h] [\-d DELIMITER] [\-t] [\-q QUOTECHAR] [\-u {0,1,2,3}] [\-b] [\-S] [\-\-blanks] [\-\-null\-value NULL_VALUES [NULL_VALUES ...]] [\-\-date\-format DATE_FORMAT] [\-\-datetime\-format DATETIME_FORMAT] [\-H] [\-K SKIP_LINES] [\-v] [\-l] [\-\-zero] [\-V] - [\-\-max\-rows MAX_ROWS] [\-\-max\-columns MAX_COLUMNS] + [\-\-expanded] [\-\-max\-rows MAX_ROWS] [\-\-max\-columns MAX_COLUMNS] [\-\-max\-column\-width MAX_COLUMN_WIDTH] [\-\-max\-precision MAX_PRECISION] [\-\-no\-number\-ellipsis] [\-y SNIFF_LIMIT] [\-I] @@ -57,6 +57,7 @@ positional arguments: optional arguments: \-h, \-\-help show this help message and exit + \-\-expanded Display each record vertically, with one field per line. \-\-max\-rows MAX_ROWS The maximum number of rows to display before truncating the data. \-\-max\-columns MAX_COLUMNS @@ -93,6 +94,19 @@ See also: \fI\%Arguments common to all tools\fP\&. The fractional part of a decimal numberal is always truncated. To control this truncation, use \fB\-\-no\-inference\fP along with \fB\-\-max\-column\-width\fP\&. .UNINDENT .UNINDENT +.sp +Use \fB\-\-expanded\fP to display each record vertically, with field names +on the left and values on the right. This is useful for wide tables. +.sp +\fB\-\-max\-rows\fP limits records. \fB\-\-max\-columns\fP limits fields +and adds an ellipsis for omitted fields. \fB\-\-max\-column\-width\fP +truncates names and values; the marker can exceed very small widths. +Numeric precision follows \fB\-\-max\-precision\fP and +\fB\-\-no\-number\-ellipsis\fP. Missing values are blank. Line breaks +and tabs appear as ↵ and ⇥. Empty input, a header without +data, or \fB\-\-max\-rows 0\fP produces no expanded records. +.sp +Expanded output is for terminal reading, not a Markdown table or CSV. .SH EXAMPLES .sp Basic use: diff --git a/tests/test_utilities/test_csvlook.py b/tests/test_utilities/test_csvlook.py index 33b5c2582..94410ad50 100644 --- a/tests/test_utilities/test_csvlook.py +++ b/tests/test_utilities/test_csvlook.py @@ -13,6 +13,7 @@ class TestCSVLook(CSVKitTestCase, EmptyFileTests): def tearDown(self): config.set_option('truncation_chars', '…') + config.set_option('number_truncation_chars', '…') def test_launch_new_instance(self): with patch.object(sys, 'argv', [self.Utility.__name__.lower(), 'examples/dummy.csv']): @@ -163,3 +164,119 @@ def test_stdin(self): ]) input_file.close() + + def test_expanded(self): + self.assertLines(['--expanded', 'examples/test_utf8.csv'], [ + '-[ RECORD 1 ]-', + 'foo | 1', + 'bar | 2', + 'baz | 3', + '-[ RECORD 2 ]-', + 'foo | 4', + 'bar | 5', + 'baz | ʤ', + ]) + + def test_expanded_alignment_and_special_characters(self): + data = 'name,notes,city\nOran,"first|second\nthird\tfourth",תל אביב\n' + with stdin_as_string(io.BytesIO(data.encode('utf-8'))): + self.assertLines(['--expanded', '-I', '-y', '0'], [ + '-[ RECORD 1 ]-', + 'name | Oran', + 'notes | first|second↵third⇥fourth', + 'city | תל אביב', + ]) + + def test_expanded_multiline_header(self): + with stdin_as_string(io.BytesIO(b'"first\nname",age\nOran,35\n')): + self.assertLines(['--expanded', '-I', '-y', '0'], [ + '-[ RECORD 1 ]-', + 'first↵name | Oran', + 'age | 35', + ]) + + def test_expanded_no_inference(self): + self.assertLines(['--expanded', '-I', 'examples/dummy.csv'], [ + '-[ RECORD 1 ]-', 'a | 1', 'b | 2', 'c | 3', + ]) + + def test_expanded_types_and_nulls(self): + data = b'active,amount,date,missing\ntrue,1234.5,2026-09-08,\nfalse,2,2026-09-09,\n' + with stdin_as_string(io.BytesIO(data)): + self.assertLines(['--expanded', '-y', '0'], [ + '-[ RECORD 1 ]-', + 'active | True', 'amount | 1,234.5', 'date | 2026-09-08', 'missing | ', + '-[ RECORD 2 ]-', + 'active | False', 'amount | 2.0', 'date | 2026-09-09', 'missing | ', + ]) + + def test_expanded_precision(self): + for options, value in [ + ([], '1.235…'), + (['--max-precision', '0'], '1…'), + (['--no-number-ellipsis'], '1.235'), + (['--max-precision', '0', '--no-number-ellipsis'], '1'), + ]: + with self.subTest(options=options): + config.set_option('number_truncation_chars', '…') + self.assertLines(['--expanded', '-y', '0', *options, 'examples/test_precision.csv'], [ + '-[ RECORD 1 ]-', f'a | {value}', + ]) + + def test_expanded_infinite_numbers(self): + with stdin_as_string(io.BytesIO(b'value\nInfinity\n-Infinity\n')): + self.assertLines(['--expanded', '-y', '0'], [ + '-[ RECORD 1 ]-', 'value | Infinity', '-[ RECORD 2 ]-', 'value | -Infinity', + ]) + + def test_expanded_max_rows(self): + self.assertLines(['--expanded', '--max-rows', '1', 'examples/dummy3.csv'], [ + '-[ RECORD 1 ]-', 'a | True', 'b | 2', 'c | 3', + ]) + + def test_expanded_zero_rows(self): + self.assertEqual(self.get_output(['--expanded', '--max-rows', '0', 'examples/dummy.csv']), '') + + def test_expanded_max_columns(self): + self.assertLines(['--expanded', '--max-columns', '1', 'examples/dummy.csv'], [ + '-[ RECORD 1 ]-', 'a | True', '... | ...', + ]) + + def test_expanded_zero_columns(self): + self.assertLines(['--expanded', '--max-columns', '0', 'examples/dummy.csv'], [ + '-[ RECORD 1 ]-', '... | ...', + ]) + + def test_expanded_max_column_width(self): + with stdin_as_string(io.BytesIO(b'long_header,city\nlong_value,London\n')): + self.assertLines(['--expanded', '-I', '--max-column-width', '6'], [ + '-[ RECORD 1 ]-', 'lon... | lon...', 'city | London', + ]) + + def test_expanded_narrow_column_width(self): + self.assertLines(['--expanded', '--max-column-width', '1', 'examples/dummy.csv'], [ + '-[ RECORD 1 ]-', 'a | ...', 'b | 2', 'c | 3', + ]) + + def test_expanded_empty_and_header_only(self): + for data in [b'', b'name,city\n']: + with self.subTest(data=data), stdin_as_string(io.BytesIO(data)): + self.assertEqual(self.get_output(['--expanded', '-y', '0']), '') + + def test_expanded_line_numbers(self): + self.assertLines(['--expanded', '--linenumbers', 'examples/dummy3.csv'], [ + '-[ RECORD 1 ]-', 'line_numbers | 1', 'a | True', 'b | 2', 'c | 3', + '-[ RECORD 2 ]-', 'line_numbers | 2', 'a | True', 'b | 4', 'c | 5', + ]) + + def test_expanded_no_header_row(self): + with stdin_as_string(io.BytesIO(b'Oran,London\n')): + self.assertLines(['--expanded', '-I', '-H', '-y', '0'], [ + '-[ RECORD 1 ]-', 'a | Oran', 'b | London', + ]) + + def test_expanded_delimiter(self): + with stdin_as_string(io.BytesIO(b'name;city\nOran;London\n')): + self.assertLines(['--expanded', '-I', '-y', '0', '-d', ';'], [ + '-[ RECORD 1 ]-', 'name | Oran', 'city | London', + ])