diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..057ff98 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,41 @@ +name: "CodeQL" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: "36 4 * * 1" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ python ] + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + queries: +security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{ matrix.language }}" diff --git a/.gitignore b/.gitignore index 213f266..9986fe3 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ __pycache__ /cli_helpers_dev .idea/ .cache/ +.vscode/ \ No newline at end of file diff --git a/AUTHORS b/AUTHORS index 4bad3dd..536b972 100644 --- a/AUTHORS +++ b/AUTHORS @@ -24,6 +24,8 @@ This project receives help from these awesome contributors: - Waldir Pimenta - Mel Dafert - Andrii Kohut +- Roland Walker +- Doug Harris Thanks ------ diff --git a/CHANGELOG b/CHANGELOG index 8563af2..0ed7bee 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,151 +1,160 @@ -Changelog -========= +# Changelog -Version 2.2.1 -------------- +## Version 2.7.0 + +(released on 2025-07-28) + +- Add `mysql` and `mysql_unicode` output formats which right-align numbers. + +## Version 2.6.0 + +(released on 2025-07-12) + +- Register the JSON formats so they are actually usable. +- Make JSON formats able to encode Decimals and None/NULLs. + +## Version 2.5.0 + +(released on 2025-07-10) + +- Added noheader CSV and TSV output formats. +- Added `jsonl` and `jsonl_escaped` output formats. + +## Version 2.4.0 + +(released on 2025-03-10) + +- Added format_timestamps preprocessor for per-column date/time formatting. + +## Version 2.3.1 + +- Don't escape newlines in `ascii` tables, and add `ascii_escaped` table format. +- Updated tabulate version to latest, to fix ImportError in pgcli. + +## Version 2.2.1 (released on 2022-01-17) -* Fix pygments tokens passed as strings +- Fix pygments tokens passed as strings -Version 2.2.0 -------------- +## Version 2.2.0 (released on 2021-08-27) -* Remove dependency on terminaltables -* Add psql_unicode table format -* Add minimal table format -* Fix pip2 installing py3-only versions -* Format unprintable bytes (eg 0x00, 0x01) as hex +- Remove dependency on terminaltables +- Add psql_unicode table format +- Add minimal table format +- Fix pip2 installing py3-only versions +- Format unprintable bytes (eg 0x00, 0x01) as hex -Version 2.1.0 -------------- +## Version 2.1.0 (released on 2020-07-29) -* Speed up output styling of tables. +- Speed up output styling of tables. -Version 2.0.1 -------------- +## Version 2.0.1 (released on 2020-05-27) -* Fix newline escaping in plain-text formatters (ascii, double, github) -* Use built-in unittest.mock instead of mock. +- Fix newline escaping in plain-text formatters (ascii, double, github) +- Use built-in unittest.mock instead of mock. -Version 2.0.0 -------------- +## Version 2.0.0 (released on 2020-05-26) -* Remove Python 2.7 and 3.5. -* Style config for missing value. +- Remove Python 2.7 and 3.5. +- Style config for missing value. -Version 1.2.1 -------------- +## Version 1.2.1 (released on 2019-06-09) -* Pin Pygments to >= 2.4.0 for tests. -* Remove Python 3.4 from tests and Trove classifier. -* Add an option to skip truncating multi-line strings. -* When truncating long strings, add ellipsis. +- Pin Pygments to >= 2.4.0 for tests. +- Remove Python 3.4 from tests and Trove classifier. +- Add an option to skip truncating multi-line strings. +- When truncating long strings, add ellipsis. -Version 1.2.0 -------------- +## Version 1.2.0 (released on 2019-04-05) -* Fix issue with writing non-ASCII characters to config files. -* Run tests on Python 3.7. -* Use twine check during packaging tests. -* Rename old tsv format to csv-tab (because it add quotes), introduce new tsv output adapter. -* Truncate long fields for tabular display. -* Return the supported table formats as unicode. -* Override tab with 4 spaces for terminal tables. +- Fix issue with writing non-ASCII characters to config files. +- Run tests on Python 3.7. +- Use twine check during packaging tests. +- Rename old tsv format to csv-tab (because it add quotes), introduce new tsv output adapter. +- Truncate long fields for tabular display. +- Return the supported table formats as unicode. +- Override tab with 4 spaces for terminal tables. -Version 1.1.0 -------------- +## Version 1.1.0 (released on 2018-10-18) -* Adds config file reading/writing. -* Style formatted tables with Pygments (optional). +- Adds config file reading/writing. +- Style formatted tables with Pygments (optional). -Version 1.0.2 -------------- +## Version 1.0.2 (released on 2018-04-07) -* Copy unit test from pgcli -* Use safe float for unit test -* Move strip_ansi from tests.utils to cli_helpers.utils +- Copy unit test from pgcli +- Use safe float for unit test +- Move strip_ansi from tests.utils to cli_helpers.utils -Version 1.0.1 -------------- +## Version 1.0.1 (released on 2017-11-27) -* Output all unicode for terminaltables, add unit test. +- Output all unicode for terminaltables, add unit test. -Version 1.0.0 -------------- +## Version 1.0.0 (released on 2017-10-11) -* Output as generator -* Use backports.csv only for py2 -* Require tabulate as a dependency instead of using vendored module. -* Drop support for Python 3.3. - +- Output as generator +- Use backports.csv only for py2 +- Require tabulate as a dependency instead of using vendored module. +- Drop support for Python 3.3. -Version 0.2.3 -------------- +## Version 0.2.3 (released on 2017-08-01) -* Fix unicode error on Python 2 with newlines in output row. -* Fixes to accept iterator. +- Fix unicode error on Python 2 with newlines in output row. +- Fixes to accept iterator. - -Version 0.2.2 -------------- +## Version 0.2.2 (released on 2017-07-16) -* Fix IndexError from being raised with uneven rows. - +- Fix IndexError from being raised with uneven rows. -Version 0.2.1 -------------- +## Version 0.2.1 (released on 2017-07-11) -* Run tests on macOS via Travis. -* Fix unicode issues on Python 2 (csv and styling output). +- Run tests on macOS via Travis. +- Fix unicode issues on Python 2 (csv and styling output). - -Version 0.2.0 -------------- +## Version 0.2.0 (released on 2017-06-23) -* Make vertical table separator more customizable. -* Add format numbers preprocessor. -* Add test coverage reports. -* Add ability to pass additional preprocessors when formatting output. -* Don't install tests.tabular_output. -* Add .gitignore -* Coverage for tox tests. -* Style formatted output with Pygments (optional). -* Fix issue where tabulate can't handle ANSI escape codes in default values. -* Run tests on Windows via Appveyor. - +- Make vertical table separator more customizable. +- Add format numbers preprocessor. +- Add test coverage reports. +- Add ability to pass additional preprocessors when formatting output. +- Don't install tests.tabular_output. +- Add .gitignore +- Coverage for tox tests. +- Style formatted output with Pygments (optional). +- Fix issue where tabulate can't handle ANSI escape codes in default values. +- Run tests on Windows via Appveyor. -Version 0.1.0 -------------- +## Version 0.1.0 (released on 2017-05-01) -* Pretty print tabular data using a variety of formatting libraries. +- Pretty print tabular data using a variety of formatting libraries. diff --git a/cli_helpers/__init__.py b/cli_helpers/__init__.py index b19ee4b..2614ce9 100644 --- a/cli_helpers/__init__.py +++ b/cli_helpers/__init__.py @@ -1 +1 @@ -__version__ = "2.2.1" +__version__ = "2.7.0" diff --git a/cli_helpers/tabular_output/delimited_output_adapter.py b/cli_helpers/tabular_output/delimited_output_adapter.py index b812456..a8d51cc 100644 --- a/cli_helpers/tabular_output/delimited_output_adapter.py +++ b/cli_helpers/tabular_output/delimited_output_adapter.py @@ -8,7 +8,7 @@ from cli_helpers.utils import filter_dict_by_key from .preprocessors import bytes_to_string, override_missing_value -supported_formats = ("csv", "csv-tab") +supported_formats = ("csv", "csv-tab", "csv-noheader", "csv-tab-noheader") preprocessors = (override_missing_value, bytes_to_string) @@ -35,9 +35,9 @@ def adapter(data, headers, table_format="csv", **kwargs): "skipinitialspace", "strict", ) - if table_format == "csv": + if table_format in ("csv", "csv-noheader"): delimiter = "," - elif table_format == "csv-tab": + elif table_format in ("csv-tab", "csv-tab-noheader"): delimiter = "\t" else: raise ValueError("Invalid table_format specified.") @@ -47,8 +47,9 @@ def adapter(data, headers, table_format="csv", **kwargs): l = linewriter() writer = csv.writer(l, **ckwargs) - writer.writerow(headers) - yield l.line + if "noheader" not in table_format: + writer.writerow(headers) + yield l.line for row in data: l.reset() diff --git a/cli_helpers/tabular_output/json_output_adapter.py b/cli_helpers/tabular_output/json_output_adapter.py new file mode 100644 index 0000000..9153fef --- /dev/null +++ b/cli_helpers/tabular_output/json_output_adapter.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +"""A JSON data output adapter""" + +from decimal import Decimal +from itertools import chain +import json + +from .preprocessors import bytes_to_string + +supported_formats = ("jsonl", "jsonl_escaped") +preprocessors = (bytes_to_string,) + + +class CustomEncoder(json.JSONEncoder): + def default(self, o): + if isinstance(o, Decimal): + return float(o) + else: + return super(CustomEncoder, self).default(o) + + +def adapter(data, headers, table_format="jsonl", **_kwargs): + """Wrap the formatting inside a function for TabularOutputFormatter.""" + if table_format == "jsonl": + ensure_ascii = False + elif table_format == "jsonl_escaped": + ensure_ascii = True + else: + raise ValueError("Invalid table_format specified.") + + for row in chain(data): + yield json.dumps( + dict(zip(headers, row, strict=True)), + cls=CustomEncoder, + separators=(",", ":"), + ensure_ascii=ensure_ascii, + ) diff --git a/cli_helpers/tabular_output/output_formatter.py b/cli_helpers/tabular_output/output_formatter.py index 6cadf6c..7fa2873 100644 --- a/cli_helpers/tabular_output/output_formatter.py +++ b/cli_helpers/tabular_output/output_formatter.py @@ -17,6 +17,7 @@ vertical_table_adapter, tabulate_adapter, tsv_output_adapter, + json_output_adapter, ) from decimal import Decimal @@ -253,3 +254,14 @@ def format_output(data, headers, format_name, **kwargs): tsv_output_adapter.preprocessors, {"table_format": tsv_format, "missing_value": "", "max_field_width": None}, ) + +for json_format in json_output_adapter.supported_formats: + TabularOutputFormatter.register_new_formatter( + json_format, + json_output_adapter.adapter, + json_output_adapter.preprocessors, + { + "table_format": json_format, + "max_field_width": None, + }, + ) diff --git a/cli_helpers/tabular_output/preprocessors.py b/cli_helpers/tabular_output/preprocessors.py index 8342d67..a47fec0 100644 --- a/cli_helpers/tabular_output/preprocessors.py +++ b/cli_helpers/tabular_output/preprocessors.py @@ -2,6 +2,7 @@ """These preprocessor functions are used to process data prior to output.""" import string +from datetime import datetime from cli_helpers import utils from cli_helpers.compat import text_type, int_types, float_types, HAS_PYGMENTS, Token @@ -125,9 +126,11 @@ def escape_newlines(data, headers, **_): return ( ( [ - v.replace("\r", r"\r").replace("\n", r"\n") - if isinstance(v, text_type) - else v + ( + v.replace("\r", r"\r").replace("\n", r"\n") + if isinstance(v, text_type) + else v + ) for v in row ] for row in data @@ -351,3 +354,44 @@ def _format_number(field, column_type): [_format_number(v, column_types[i]) for i, v in enumerate(row)] for row in data ) return data, headers + + +def format_timestamps(data, headers, column_date_formats=None, **_): + """Format timestamps according to user preference. + + This allows for per-column formatting for date, time, or datetime like data. + + Add a `column_date_formats` section to your config file with separate lines for each column + that you'd like to specify a format using `name=format`. Use standard Python strftime + formatting strings + + Example: `signup_date = "%Y-%m-%d"` + + :param iterable data: An :term:`iterable` (e.g. list) of rows. + :param iterable headers: The column headers. + :param str column_date_format: The format strings to use for specific columns. + :return: The processed data and headers. + :rtype: tuple + + """ + if column_date_formats is None: + return iter(data), headers + + def _format_timestamp(value, name, column_date_formats): + if name not in column_date_formats: + return value + try: + dt = datetime.fromisoformat(value) + return dt.strftime(column_date_formats[name]) + except (ValueError, TypeError): + # not a date + return value + + data = ( + [ + _format_timestamp(v, headers[i], column_date_formats) + for i, v in enumerate(row) + ] + for row in data + ) + return data, headers diff --git a/cli_helpers/tabular_output/tabulate_adapter.py b/cli_helpers/tabular_output/tabulate_adapter.py index a7eabc0..f175f20 100644 --- a/cli_helpers/tabular_output/tabulate_adapter.py +++ b/cli_helpers/tabular_output/tabulate_adapter.py @@ -52,9 +52,49 @@ with_header_hide=None, ) +tabulate._table_formats["ascii_escaped"] = tabulate.TableFormat( + lineabove=tabulate.Line("+", "-", "+", "+"), + linebelowheader=tabulate.Line("+", "-", "+", "+"), + linebetweenrows=None, + linebelow=tabulate.Line("+", "-", "+", "+"), + headerrow=tabulate.DataRow("|", "|", "|"), + datarow=tabulate.DataRow("|", "|", "|"), + padding=1, + with_header_hide=None, +) + +tabulate._table_formats["mysql"] = tabulate.TableFormat( + lineabove=tabulate.Line("+", "-", "+", "+"), + linebelowheader=tabulate.Line("+", "-", "+", "+"), + linebetweenrows=None, + linebelow=tabulate.Line("+", "-", "+", "+"), + headerrow=tabulate.DataRow("|", "|", "|"), + datarow=tabulate.DataRow("|", "|", "|"), + padding=1, + with_header_hide=None, +) + +tabulate._table_formats["mysql_unicode"] = tabulate.TableFormat( + lineabove=tabulate.Line("┌", "─", "┬", "┐"), + linebelowheader=tabulate.Line("├", "─", "┼", "┤"), + linebetweenrows=None, + linebelow=tabulate.Line("└", "─", "┴", "┘"), + headerrow=tabulate.DataRow("│", "│", "│"), + datarow=tabulate.DataRow("│", "│", "│"), + padding=1, + with_header_hide=None, +) + # "minimal" is the same as "plain", but without headers tabulate._table_formats["minimal"] = tabulate._table_formats["plain"] +tabulate.multiline_formats["psql_unicode"] = "psql_unicode" +tabulate.multiline_formats["double"] = "double" +tabulate.multiline_formats["ascii"] = "ascii" +tabulate.multiline_formats["minimal"] = "minimal" +tabulate.multiline_formats["mysql"] = "mysql" +tabulate.multiline_formats["mysql_unicode"] = "mysql_unicode" + supported_markup_formats = ( "mediawiki", "html", @@ -66,6 +106,7 @@ ) supported_table_formats = ( "ascii", + "ascii_escaped", "plain", "simple", "minimal", @@ -78,11 +119,18 @@ "rst", "github", "double", + "mysql", + "mysql_unicode", ) supported_formats = supported_markup_formats + supported_table_formats -default_kwargs = {"ascii": {"numalign": "left"}} +default_kwargs = { + "ascii": {"numalign": "left"}, + "ascii_escaped": {"numalign": "left"}, + "mysql": {"numalign": "right"}, + "mysql_unicode": {"numalign": "right"}, +} headless_formats = ("minimal",) diff --git a/cli_helpers/tabular_output/tsv_output_adapter.py b/cli_helpers/tabular_output/tsv_output_adapter.py index 75518b3..c0dfc34 100644 --- a/cli_helpers/tabular_output/tsv_output_adapter.py +++ b/cli_helpers/tabular_output/tsv_output_adapter.py @@ -7,11 +7,17 @@ from itertools import chain from cli_helpers.utils import replace -supported_formats = ("tsv",) +supported_formats = ("tsv", "tsv_noheader") preprocessors = (override_missing_value, bytes_to_string, convert_to_string) -def adapter(data, headers, **kwargs): +def adapter(data, headers, table_format="tsv", **kwargs): """Wrap the formatting inside a function for TabularOutputFormatter.""" - for row in chain((headers,), data): - yield "\t".join((replace(r, (("\n", r"\n"), ("\t", r"\t"))) for r in row)) + if table_format == "tsv": + for row in chain((headers,), data): + yield "\t".join((replace(r, (("\n", r"\n"), ("\t", r"\t"))) for r in row)) + elif table_format == "tsv_noheader": + for row in data: + yield "\t".join((replace(r, (("\n", r"\n"), ("\t", r"\t"))) for r in row)) + else: + raise ValueError(f"Invalid table_format specified: {table_format}.") diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 319655d..b304de2 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -15,7 +15,7 @@ CLI Helpers provides a simple way to display your tabular data (columns/rows) in >>> data = [[1, 'Asgard', True], [2, 'Camelot', False], [3, 'El Dorado', True]] >>> headers = ['id', 'city', 'visited'] - >>> print(tabular_output.format_output(data, headers, format_name='simple')) + >>> print("\n".join(tabular_output.format_output(iter(data), headers, format_name='simple'))) id city visited ---- --------- --------- @@ -57,7 +57,7 @@ same data from our first example and put it in the ``fancy_grid`` format:: >>> data = [[1, 'Asgard', True], [2, 'Camelot', False], [3, 'El Dorado', True]] >>> headers = ['id', 'city', 'visited'] - >>> print(formatter.format_output(data, headers, format_name='fancy_grid')) + >>> print("\n".join(formatter.format_output(iter(data), headers, format_name='fancy_grid'))) ╒══════╤═══════════╤═══════════╕ │ id │ city │ visited │ ╞══════╪═══════════╪═══════════╡ @@ -70,7 +70,7 @@ same data from our first example and put it in the ``fancy_grid`` format:: That was easy! How about CLI Helper's vertical table layout? - >>> print(formatter.format_output(data, headers, format_name='vertical')) + >>> print("\n".join(formatter.format_output(iter(data), headers, format_name='vertical'))) ***************************[ 1. row ]*************************** id | 1 city | Asgard @@ -93,7 +93,7 @@ object, you can specify a default formatter so you don't have to pass the format name each time you want to format your data:: >>> formatter = TabularOutputFormatter(format_name='plain') - >>> print(formatter.format_output(data, headers)) + >>> print("\n".join(formatter.format_output(iter(data), headers))) id city visited 1 Asgard True 2 Camelot False @@ -115,13 +115,13 @@ formats, we could:: >>> data = [[1, 1.5], [2, 19.605], [3, 100.0]] >>> headers = ['id', 'rating'] - >>> print(format_output(data, headers, format_name='simple', disable_numparse=True)) + >>> print("\n".join(format_output(iter(data), headers, format_name='simple', disable_numparse=True))) id rating ---- -------- 1 1.5 2 19.605 3 100.0 - >>> print(format_output(data, headers, format_name='simple', disable_numparse=False)) + >>> print("\n".join(format_output(iter(data), headers, format_name='simple', disable_numparse=False))) id rating ---- -------- 1 1.5 @@ -140,7 +140,7 @@ far-fetched example to prove the point:: >>> step = 3 >>> data = [range(n, n + step) for n in range(0, 9, step)] >>> headers = 'abc' - >>> print(format_output(data, headers, format_name='simple')) + >>> print("\n".join(format_output(iter(data), headers, format_name='simple'))) a b c --- --- --- 0 1 2 diff --git a/requirements-dev.txt b/requirements-dev.txt index 7846a4d..9b331c2 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,9 +1,9 @@ autopep8==1.3.3 -codecov==2.0.9 +codecov==2.1.13 coverage==4.3.4 black>=20.8b1 Pygments>=2.4.0 -pytest==3.0.7 +pytest==7.4.3 pytest-cov==2.4.0 Sphinx==1.5.5 tox==2.7.0 diff --git a/setup.py b/setup.py index 46fbdc8..16ecf5b 100755 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ def open_file(filename): long_description_content_type="text/x-rst", install_requires=[ "configobj >= 5.0.5", - "tabulate[widechars] >= 0.8.2", + "tabulate[widechars] >= 0.9.0", ], extras_require={ "styles": ["Pygments >= 1.6"], diff --git a/tests/tabular_output/test_delimited_output_adapter.py b/tests/tabular_output/test_delimited_output_adapter.py index 86a622e..c9553ba 100644 --- a/tests/tabular_output/test_delimited_output_adapter.py +++ b/tests/tabular_output/test_delimited_output_adapter.py @@ -42,6 +42,36 @@ def test_csv_wrapper(): list(output) +def test_csv_noheader_wrapper(): + """Test the delimited output adapter without headers.""" + # Test comma-delimited output. + data = [["abc", "1"], ["d", "456"]] + headers = ["letters", "number"] + output = delimited_output_adapter.adapter( + iter(data), + headers, + table_format="csv-noheader", + dialect="unix", + ) + assert "\n".join(output) == dedent( + '''\ + "abc","1"\n\ + "d","456"''' + ) + + # Test tab-delimited output. + data = [["abc", "1"], ["d", "456"]] + headers = ["letters", "number"] + output = delimited_output_adapter.adapter( + iter(data), headers, table_format="csv-tab-noheader", dialect="unix" + ) + assert "\n".join(output) == dedent( + '''\ + "abc"\t"1"\n\ + "d"\t"456"''' + ) + + def test_unicode_with_csv(): """Test that the csv wrapper can handle non-ascii characters.""" data = [["观音", "1"], ["Ποσειδῶν", "456"]] diff --git a/tests/tabular_output/test_json_output_adapter.py b/tests/tabular_output/test_json_output_adapter.py new file mode 100644 index 0000000..53dfeae --- /dev/null +++ b/tests/tabular_output/test_json_output_adapter.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +"""Test the json output adapter.""" + +from __future__ import unicode_literals + +from decimal import Decimal + +from cli_helpers.tabular_output import json_output_adapter + + +def test_jsonl_wrapper(): + """Test the jsonl output adapter.""" + # Test jsonl output. + data = [["ab\r\nc", 1], ["d", 456]] + headers = ["letters", "number"] + output = json_output_adapter.adapter(iter(data), headers, table_format="jsonl") + assert ( + "\n".join(output) + == """{"letters":"ab\\r\\nc","number":1}\n{"letters":"d","number":456}""" + ) + + +def test_unicode_with_jsonl(): + """Test that the jsonl wrapper can pass through non-ascii characters.""" + data = [["观音", 1], ["Ποσειδῶν", 456]] + headers = ["letters", "number"] + output = json_output_adapter.adapter(data, headers, table_format="jsonl") + assert ( + "\n".join(output) + == """{"letters":"观音","number":1}\n{"letters":"Ποσειδῶν","number":456}""" + ) + + +def test_decimal_with_jsonl(): + """Test that the jsonl wrapper can pass through Decimal values.""" + data = [["ab\r\nc", 1], ["d", Decimal(4.56)]] + headers = ["letters", "number"] + output = json_output_adapter.adapter(iter(data), headers, table_format="jsonl") + assert ( + "\n".join(output) + == """{"letters":"ab\\r\\nc","number":1}\n{"letters":"d","number":4.56}""" + ) + + +def test_null_with_jsonl(): + """Test that the jsonl wrapper can pass through null values.""" + data = [["ab\r\nc", None], ["d", None]] + headers = ["letters", "value"] + output = json_output_adapter.adapter(iter(data), headers, table_format="jsonl") + assert ( + "\n".join(output) + == """{"letters":"ab\\r\\nc","value":null}\n{"letters":"d","value":null}""" + ) + + +def test_unicode_with_jsonl_esc(): + """Test that the jsonl_escaped wrapper JSON-escapes non-ascii characters.""" + data = [["观音", 1], ["Ποσειδῶν", 456]] + headers = ["letters", "number"] + output = json_output_adapter.adapter(data, headers, table_format="jsonl_escaped") + assert ( + "\n".join(output) + == """{"letters":"\\u89c2\\u97f3","number":1}\n{"letters":"\\u03a0\\u03bf\\u03c3\\u03b5\\u03b9\\u03b4\\u1ff6\\u03bd","number":456}""" + ) diff --git a/tests/tabular_output/test_output_formatter.py b/tests/tabular_output/test_output_formatter.py index b307c1c..4bce9e4 100644 --- a/tests/tabular_output/test_output_formatter.py +++ b/tests/tabular_output/test_output_formatter.py @@ -21,6 +21,41 @@ def test_tabular_output_formatter(): ["hi", Decimal("1.1")], ["Pablo\rß\n", 0], ] + expected = dedent( + """\ + +-------+---------+ + | text | numeric | + +-------+---------+ + | abc | 1 | + | defg | 11.1 | + | hi | 1.1 | + | Pablo | 0 | + | ß | | + +-------+---------+""" + ) + + print(expected) + print( + "\n".join( + TabularOutputFormatter().format_output( + iter(data), headers, format_name="ascii" + ) + ) + ) + assert expected == "\n".join( + TabularOutputFormatter().format_output(iter(data), headers, format_name="ascii") + ) + + +def test_tabular_output_escaped(): + """Test the ascii_escaped output format.""" + headers = ["text", "numeric"] + data = [ + ["abc", Decimal(1)], + ["defg", Decimal("11.1")], + ["hi", Decimal("1.1")], + ["Pablo\rß\n", 0], + ] expected = dedent( """\ +------------+---------+ @@ -37,12 +72,86 @@ def test_tabular_output_formatter(): print( "\n".join( TabularOutputFormatter().format_output( - iter(data), headers, format_name="ascii" + iter(data), headers, format_name="ascii_escaped" ) ) ) assert expected == "\n".join( - TabularOutputFormatter().format_output(iter(data), headers, format_name="ascii") + TabularOutputFormatter().format_output( + iter(data), headers, format_name="ascii_escaped" + ) + ) + + +def test_tabular_output_mysql(): + """Test the mysql output format.""" + headers = ["text", "numeric"] + data = [ + ["abc", Decimal(1)], + ["defg", Decimal("11.1")], + ["hi", Decimal("1.1")], + ["Pablo\rß\n", 0], + ] + expected = dedent( + """\ + +-------+---------+ + | text | numeric | + +-------+---------+ + | abc | 1 | + | defg | 11.1 | + | hi | 1.1 | + | Pablo | 0 | + | ß | | + +-------+---------+""" + ) + + print(expected) + print( + "\n".join( + TabularOutputFormatter().format_output( + iter(data), headers, format_name="mysql" + ) + ) + ) + assert expected == "\n".join( + TabularOutputFormatter().format_output(iter(data), headers, format_name="mysql") + ) + + +def test_tabular_output_mysql_unicode(): + """Test the mysql_unicode output format.""" + headers = ["text", "numeric"] + data = [ + ["abc", Decimal(1)], + ["defg", Decimal("11.1")], + ["hi", Decimal("1.1")], + ["Pablo\rß\n", 0], + ] + expected = dedent( + """\ + ┌───────┬─────────┐ + │ text │ numeric │ + ├───────┼─────────┤ + │ abc │ 1 │ + │ defg │ 11.1 │ + │ hi │ 1.1 │ + │ Pablo │ 0 │ + │ ß │ │ + └───────┴─────────┘""" + ) + + print(expected) + print( + "\n".join( + TabularOutputFormatter().format_output( + iter(data), headers, format_name="mysql_unicode" + ) + ) + ) + assert expected == "\n".join( + TabularOutputFormatter().format_output( + iter(data), headers, format_name="mysql_unicode" + ) ) @@ -140,6 +249,13 @@ def test_unsupported_format(): formatter.format_output((), (), format_name="foobar") +def test_supported_json_formats(): + """Test that the JSONl formats are known.""" + formatter = TabularOutputFormatter() + assert "jsonl" in formatter.supported_formats + assert "jsonl_escaped" in formatter.supported_formats + + def test_tabulate_ansi_escape_in_default_value(): """Test that ANSI escape codes work with tabulate.""" diff --git a/tests/tabular_output/test_preprocessors.py b/tests/tabular_output/test_preprocessors.py index e428bfa..5ebd06d 100644 --- a/tests/tabular_output/test_preprocessors.py +++ b/tests/tabular_output/test_preprocessors.py @@ -16,6 +16,7 @@ override_tab_value, style_output, format_numbers, + format_timestamps, ) if HAS_PYGMENTS: @@ -348,3 +349,25 @@ def test_enforce_iterable(): assert False, "{} doesn't return iterable".format(name) if isinstance(preprocessed[1], types.GeneratorType): assert False, "{} returns headers as iterator".format(name) + + +def test_format_timestamps(): + data = ( + ("name1", "2024-12-13T18:32:22", "2024-12-13T19:32:22", "2024-12-13T20:32:22"), + ("name2", "2025-02-13T02:32:22", "2025-02-13T02:32:22", "2025-02-13T02:32:22"), + ("name3", None, "not-actually-timestamp", "2025-02-13T02:32:22"), + ) + headers = ["name", "date_col", "datetime_col", "unchanged_col"] + column_date_formats = { + "date_col": "%Y-%m-%d", + "datetime_col": "%I:%M:%S %m/%d/%y", + } + result_data, result_headers = format_timestamps(data, headers, column_date_formats) + + expected = [ + ["name1", "2024-12-13", "07:32:22 12/13/24", "2024-12-13T20:32:22"], + ["name2", "2025-02-13", "02:32:22 02/13/25", "2025-02-13T02:32:22"], + ["name3", None, "not-actually-timestamp", "2025-02-13T02:32:22"], + ] + assert expected == list(result_data) + assert headers == result_headers diff --git a/tests/tabular_output/test_tsv_output_adapter.py b/tests/tabular_output/test_tsv_output_adapter.py index 9249d87..8097a77 100644 --- a/tests/tabular_output/test_tsv_output_adapter.py +++ b/tests/tabular_output/test_tsv_output_adapter.py @@ -23,6 +23,19 @@ def test_tsv_wrapper(): ) +def test_tsv_headerless_wrapper(): + """Test the tsv headerless_output adapter.""" + # Test tab-delimited output. + data = [["ab\r\nc", "1"], ["d", "456"]] + headers = ["letters", "number"] + output = tsv_output_adapter.adapter(iter(data), headers, table_format="tsv_noheader") + assert "\n".join(output) == dedent( + """\ + ab\r\\nc\t1\n\ + d\t456""" + ) + + def test_unicode_with_tsv(): """Test that the tsv wrapper can handle non-ascii characters.""" data = [["观音", "1"], ["Ποσειδῶν", "456"]]