AnyCol: Strings spaltenförmig anordnen

Stellt hier eure Projekte vor.
Internetseiten, Skripte, und alles andere bzgl. Python.
Antworten
Benutzeravatar
snafu
User
Beiträge: 6738
Registriert: Donnerstag 21. Februar 2008, 17:31
Wohnort: Gelsenkirchen

Ich hatte vor einer ganzen Weile mal ein sehr ähnliches Projekt, was aber irgendwann eingeschlafen ist. Hier der Neustart (objektorientiert und beschränkt auf die Basics):

Code: Alles auswählen

#!/usr/bin/env python3
from itertools import zip_longest
from math import ceil
from shutil import get_terminal_size


class Column:
    def __init__(self, strings, adjuster=str.ljust):
        self.strings = strings
        self.adjuster = adjuster

    def __iter__(self):
        width = self.get_width()
        for string in self.strings:
            yield self.adjuster(string, width)

    def get_width(self):
        return max(len(s) for s in self.strings)


class ColumnCalculator:
    def __init__(self, strings, spacing):
        self.strings = strings
        self.spacing = spacing

    def build_columns(self, max_columns):
        if max_columns <= 0:
            return []
        num_lines = ceil(len(self.strings) / max_columns)
        rng = range(0, len(self.strings), num_lines)
        return [Column(self.strings[i:(i + num_lines)]) for i in rng]

    def fits_in_line(self, columns, line_width):
        total_column_widths = sum(col.get_width() for col in columns)
        total_spacing = (len(columns) - 1) * self.spacing
        total_width = total_column_widths + total_spacing + 1
        return total_width <= line_width

    def guess_columns(self, line_width=None):
        if line_width is None:
            line_width = get_terminal_size().columns
        columns = self.build_columns(min(line_width, len(self.strings)))
        while len(columns) > 1 and not self.fits_in_line(columns, line_width):
            columns = self.build_columns(len(columns) - 1)
        return columns


class View:
    def __init__(self, strings, spacer, line_width):
        self.strings = strings
        self.spacer = spacer
        self.line_width = line_width

    def __str__(self):
        return "\n".join(self.get_lines())

    def get_columns(self):
        calculator = ColumnCalculator(self.strings, len(self.spacer))
        return calculator.guess_columns(self.line_width)

    def get_lines(self):
        lines = zip_longest(*self.get_columns(), fillvalue="")
        return [self.spacer.join(line) for line in lines]


def columnize(items, spacer="  ", line_width=None):
    strings = [str(item) for item in items]
    return View(strings, spacer, line_width)
Anwendungsbeispiel:

Code: Alles auswählen

from pathlib import Path
from anycol import columnize

filenames = Path().iterdir()
print(columnize(filenames, spacer=" | "))
Benutzeravatar
__blackjack__
User
Beiträge: 13077
Registriert: Samstag 2. Juni 2018, 10:21
Wohnort: 127.0.0.1
Kontaktdaten:

`Column` lässt sich an zwei Stellen etwas vereinfachen: `__iter__()` könnte einen Generator per Generatorausdruck zurückgegeben. Und `max()` kennt ein `key`-Argument:

Code: Alles auswählen

class Column:
    def __init__(self, strings, adjuster=str.ljust):
        self.strings = strings
        self.adjuster = adjuster

    def __iter__(self):
        width = self.get_width()
        return (self.adjuster(string, width) for string in self.strings)

    def get_width(self):
        return max(self.strings, key=len)
`rng` ist eine übliche Abkürzung für „random number generator“. Muss man dem `range()`-Objekt an der Stelle denn überhaupt einen Namen geben? Der ist ja supergenerisch und das wird auch nur an einer Stelle und zwar gleich in der nächsten Anweisung verwendet.
„All religions are the same: religion is basically guilt, with different holidays.” — Cathy Ladman
Benutzeravatar
snafu
User
Beiträge: 6738
Registriert: Donnerstag 21. Februar 2008, 17:31
Wohnort: Gelsenkirchen

__blackjack__ hat geschrieben: Sonntag 16. Oktober 2022, 16:08 `Column` lässt sich an zwei Stellen etwas vereinfachen: `__iter__()` könnte einen Generator per Generatorausdruck zurückgegeben.
Ich weiß, sehe da aber jetzt nicht so den großen Unterschied.
__blackjack__ hat geschrieben: Sonntag 16. Oktober 2022, 16:08 Und `max()` kennt ein `key`-Argument
Dann tut es aber auch etwas ganz anderes. Die Länge wird zwar ebenso geprüft, aber das Ergebnis ist der längste String der Spalte. Gewünscht ist jedoch die Länge des größten Strings als Zahl.

Und bezüglich rng: Gerade weil die Verwendung direkt im Anschluss folgt, werden es wohl die meisten Leute verstehen. Ich versuche, zu lange Zeilen zu vermeiden und habe das deshalb so geschrieben.
Benutzeravatar
__blackjack__
User
Beiträge: 13077
Registriert: Samstag 2. Juni 2018, 10:21
Wohnort: 127.0.0.1
Kontaktdaten:

Wenn man das nicht an einen Namen bindet, sieht's halt so aus:

Code: Alles auswählen

        return [
            Column(self.strings[i : i + lines_per_column])
            for i in range(0, len(self.strings), lines_per_column)
        ]
Ja, eine Zeile mehr, aber ein zusätzliches ] auf einer Zeile ist ja visuell ”leichtgewichtig”. Wobei ich an der Stelle wahrscheinlich auch auf etwas aus `more_itertools` zurückgegriffen hätte, dann wäre das wieder eine Zeile:

Code: Alles auswählen

        return list(map(Column, chunked(self.strings, lines_per_column)))
„All religions are the same: religion is basically guilt, with different holidays.” — Cathy Ladman
Benutzeravatar
snafu
User
Beiträge: 6738
Registriert: Donnerstag 21. Februar 2008, 17:31
Wohnort: Gelsenkirchen

chunked() ist ganz nett, aber ich tue mich schwer mit externen Abhängigkeiten, die man mit einem Zweizeiler nachbauen kann. Das lohnt sich IMHO dann nicht wirklich. Übrigens habe ich gerade gesehen, dass mit itertools.batched() ab Python 3.12 wohl etwas ähnliches Einzug in die Standard-Bibliothek halten wird. Siehe Commit: https://github.com/python/cpython/commi ... 520504fa7e
Benutzeravatar
__blackjack__
User
Beiträge: 13077
Registriert: Samstag 2. Juni 2018, 10:21
Wohnort: 127.0.0.1
Kontaktdaten:

Ich tue mich im Gegensatz dazu schwer damit in jedem Projekt immer wieder selber die immer gleichen Zweizeiler zu schreiben, wenn es die in einem Modul gibt das in der Standardbibliothek in der Dokumentation von `itertools` erwähnt wird, das da die ganzen Rezepte und mehr schon implementiert sind. Irgendwann macht man doch mal einen Fehler, in *dem* Modul haben die einen Namen der dann bei allen, die das benutzen gleich ist, und manche von den Rezepten sind unschön oder nicht so ganz offensichtlich umgesetzt, aus Performancegründen, da ist es nett das nicht selbst im Quelltext stehen haben zu müssen. 🤓
„All religions are the same: religion is basically guilt, with different holidays.” — Cathy Ladman
Benutzeravatar
snafu
User
Beiträge: 6738
Registriert: Donnerstag 21. Februar 2008, 17:31
Wohnort: Gelsenkirchen

Aktueller Stand: https://github.com/seblin/anycol/blob/main/anycol.py

Es wird nun das externe Modul ``more_itertools`` benötigt (die passende setup.py wird nachgeliefert). Hinzugekommen ist die Unterstützung für Wörterbücher. Hier gibt es aber noch Probleme, wenn die Werte zu lang sind, um in eine Zeile zu passen. Es gibt zwar keine Fehlermeldung, aber der Zeilenumbruch des Terminals verschiebt dann halt alles. Da bin ich aktuell dran und habe in der Item-Klasse bereits eine Methode für's Wrapping eingebaut, die aber bisher noch ungenutzt ist. Im nächsten Schritt füge ich das zum "großen Ganzen" zusammen, dann wird die Darstellung von bestimmten Wörterbüchern (z.B. os.environ) wesentlich hübscher aussehen.
Benutzeravatar
snafu
User
Beiträge: 6738
Registriert: Donnerstag 21. Februar 2008, 17:31
Wohnort: Gelsenkirchen

Nochmal etwas kompakter mit 20 Zeilen weniger gelöst:

Code: Alles auswählen

from collections.abc import Mapping
from itertools import zip_longest
from math import ceil
from shutil import get_terminal_size

class Column:
    def __init__(self, strings):
        self.strings = strings

    def get_width(self):
        return max(map(len, self.strings), default=0)

    def get_spec(self):
        return f"{{:<{self.get_width()}}}"

    def __iter__(self):
        return map(self.get_spec().format, self.strings)

    def __repr__(self):
        return f"{type(self).__name__}({self.strings})"


class ColumnBuilder:
    def __init__(self, values, spacer="  ", line_width=None):
        self.strings = list(map(str, values))
        self.spacer = spacer
        if line_width is None:
            line_width = get_terminal_size().columns
        self.line_width = line_width

    def get_columns(self, max_columns):
        if not self.strings or max_columns <= 0:
            raise ValueError("No items or columns <= 0")
        num_lines = ceil(len(self.strings) / max_columns)
        chunks = (
            self.strings[i * num_lines : (i + 1) * num_lines]
            for i in range(ceil(len(self.strings) / num_lines))
        )
        return list(map(Column, chunks))

    def get_candidates(self):
        max_columns = min(len(self.strings), self.line_width)
        while max_columns > 0:
            columns = self.get_columns(max_columns)
            max_columns = len(columns) - 1
            yield columns

    def fits_in_line(self, columns):
        total_widths = sum(col.get_width() for col in columns)
        total_spacing = len(self.spacer) * (len(columns) - 1)
        return total_widths + total_spacing < self.line_width

    def find_fitting_columns(self, allow_exceeding=True):
        for columns in self.get_candidates():
            if self.fits_in_line(columns):
                return columns
        if not allow_exceeding:
            raise ValueError("Items do not fit in line")
        return self.get_columns(1)


def colummnize(data, spacer="  ", line_width=None):
    if isinstance(data, Mapping):
        # TODO: Implement wrapping for keys and/or values
        # if they do not fit in line
        columns = [Column(data.keys()), Column(data.values())]
    else:
        builder = ColumnBuilder(data, spacer, line_width)
        columns = builder.find_fitting_columns()
    return "\n".join(
        spacer.join(filter(None, line)).rstrip()
        for line in zip_longest(*columns)
    )

def cprint(data, spacer="  ", line_width=None):
    print(colummnize(data, spacer, line_width))
Antworten