# 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]]


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