3
\$\begingroup\$

Problem Description

Printing the result of adding two numpy arrays together is very ugly.

Here we will use these numpy arrays as an example:

import numpy as np
arr_2d = np.array([[3, 3, 3],
       [3, 3, 1],
       [2, 3, 1]])
arr_1d = np.array([3,4,5])

Here are a few of the ugly ways to print the formula arr_2d + arr_1d that are low-effort and ugly:

  1. Format strings
>>> print(f"{arr_1d}\n+\n{arr_2d}\n=\n{arr_1d + arr_2d}")
[3 4 5]
+
[[3 3 3]
 [3 3 1]
 [2 3 1]]
=
[[6 7 8]
 [6 7 6]
 [5 7 6]]

method 2 - printing as a list

>>> print([arr_1d,'+',arr_2d,'=',(arr_1d + arr_2d)])
[array([3, 4, 5]), '+', array([[3, 3, 3],
       [3, 3, 1],
       [2, 3, 1]]), '=', array([[6, 7, 8],
       [6, 7, 6],
       [5, 7, 6]])]

Solution

To solve this problem I used the following code:

def multiline_concat (*args):
    """
    Concatenates arguments into a single str. 
    Supports multiline strs and arguments that become multiline strs when cast to str
    """
    str_args = []
    for arg_index, arg in enumerate(args):
        try:
            str_args.append(str(arg))
        except TypeError as e:
            raise TypeError(f"""ERROR: parameter passed to multiline_concat at index
                                {arg_index} of type {type(arg)} cannot be converted to a str""")
    output_rows = []
    row_len = 0
    for col in str_args: # each value in *args is a column
        col_lines = col.split('\n')
        col_width = 0
        for row_index, cell in enumerate(col_lines):
            if len(output_rows) <= row_index: # Row doesn't exist yet so we add it.
                output_rows.append("")
            col_width = max(len(cell), col_width)
            # padding the row so cells in this column start at the same index, then appending the cell
            output_rows[row_index] = output_rows[row_index].ljust(row_len) + cell
        row_len += col_width
    return '\n'.join(output_rows)

we can now concatenate multi-line strings like this:

>>> print(multiline_concat(arr_1d, '+', arr_2d, '=',(arr_1d + arr_2d)))
[3 4 5] + [[3 3 3]  = [[6 7 8]
           [3 3 1]     [6 7 6]
           [2 3 1]]    [5 7 6]]
\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

A while ago I created the same function. I was creating a number of formatting functions, and found working against str or Any to introduce a significant amount of boiler plate. As such I'd recommend splitting the function into two:

  1. A core function interacting with Iterable[str], and
  2. A helper function which interacts with Any and returns a str.

Looking at the helper code:

  1. Not a fan of whitespace after function names def multiline_concat (*args).
  2. Not a fan of the names you're using around arg and would change to column.
  3. You don't need as e.
  4. The multiline string is going to keep a ton of whitespace in the error message, you shouldn't use a multiline here. Instead use two normal strings with implicit concatenation.
  5. I would introduce a sep variable to allow specifying a custom separator.
from collections.abc import Iterable, Iterator
from typing import Any


def _multiline_concat(
    *columns: Iterable[str],
    sep: str = " ",
) -> Iterator[str]:
    output_rows = []
    row_len = 0
    for col_lines in columns: # each value in *args is a column
        col_width = 0
        for row_index, cell in enumerate(col_lines):
            if len(output_rows) <= row_index: # Row doesn't exist yet so we add it.
                output_rows.append("")
            col_width = max(len(cell), col_width)
            # padding the row so cells in this column start at the same index, then appending the cell
            output_rows[row_index] = output_rows[row_index].ljust(row_len) + cell
        row_len += col_width
    yield from output_rows


def multiline_concat(
    *columns: Any,
    sep: str = " ",
) -> str:
    """
    Concatenates arguments into a single str. 
    Supports multiline strs and arguments that become multiline strs when cast to str
    """
    columns_: list[list[str]] = []
    for column in columns:
        try:
            columns_.append(str(column).splitlines())
        except TypeError:
            raise TypeError(
                f"ERROR: parameter passed to multiline_concat at index"
                f" {len(columns_)} of type {type(column)} cannot be converted to a str"
            )
    return "\n".join(_multiline_concat(*columns_, sep=sep))

Looking at the core function _multiline_concat:

  1. Once you have col_lines you can col_width by getting the max size:

    col_width = max(len(item) for item in col_lines)
    
  2. The approach is clever and is simple if you are not aware of itertools.zip_longest and zip.

    With zip_longest you can iterate through all the columns getting the row each time. Simplifying the code, as output_rows and row_len are likely a little trick to work around.

    With zip we merge the row with the pre-computed widths, so we can item.ljust(width) each value.

import itertools


def _multiline_concat(
    *columns: Iterable[str],
    sep: str = " ",
) -> Iterator[str]:
    columns_ = [list(c) for c in columns]
    columns_width = [
        max(len(item) for item in column)
        for column in columns_
    ]
    for row in itertools.zip_longest(*columns_, fillvalue=""):
        yield sep.join(
            item.ljust(width)
            for item, width in zip(row, columns_width)
        )
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.