Skip to content
Open
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
1 change: 1 addition & 0 deletions AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,4 @@ The following individuals have contributed code to csvkit:
* lamdevhs
* Sai Asish Y
* Peng-Yu Chen
* Oran S.Cohen
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
56 changes: 55 additions & 1 deletion csvkit/utilities/csvlook.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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.')
Expand Down Expand Up @@ -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,
Expand All @@ -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()
Expand Down
26 changes: 24 additions & 2 deletions docs/scripts/csvlook.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
Expand All @@ -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``.

Expand All @@ -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
Expand Down
16 changes: 15 additions & 1 deletion man/csvlook.1
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
117 changes: 117 additions & 0 deletions tests/test_utilities/test_csvlook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']):
Expand Down Expand Up @@ -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',
])