Sfoglia il codice sorgente

Add column command

Implements git column command for formatting input data into columns.
Supports multiple layout modes (column, row, plain), custom padding,
indentation, and width control.

Fixes #1837
Jelmer Vernooij 2 mesi fa
parent
commit
cf5835092f
2 ha cambiato i file con 304 aggiunte e 0 eliminazioni
  1. 206 0
      dulwich/cli.py
  2. 98 0
      tests/test_cli.py

+ 206 - 0
dulwich/cli.py

@@ -476,6 +476,148 @@ def write_columns(
             out.write("".join(lines).rstrip() + "\n")
 
 
+def format_columns(
+    items: list[str],
+    width: int | None = None,
+    mode: str = "column",
+    padding: int = 1,
+    indent: str = "",
+    nl: str = "\n",
+) -> str:
+    r"""Format items into columns with various layout modes.
+
+    Args:
+        items: List of strings to format
+        width: Terminal width (auto-detected if None)
+        mode: Layout mode - "column" (fill columns first), "row" (fill rows first),
+              "plain" (one column), or add ",dense" for unequal column widths
+        padding: Number of spaces between columns
+        indent: String to prepend to each line
+        nl: String to append to each line (including newline)
+
+    Returns:
+        Formatted string with items in columns
+
+    Examples:
+        >>> format_columns(["a", "b", "c"], width=20, mode="column")
+        "a  b\\nc\\n"
+        >>> format_columns(["a", "b", "c"], width=20, mode="row")
+        "a  b  c\\n"
+    """
+    if not items:
+        return ""
+
+    if width is None:
+        width = detect_terminal_width()
+
+    # Parse mode
+    mode_parts = mode.split(",")
+    layout_mode = "column"
+    dense = False
+
+    for part in mode_parts:
+        part = part.strip()
+        if part in ("column", "row", "plain"):
+            layout_mode = part
+        elif part == "dense":
+            dense = True
+        elif part == "nodense":
+            dense = False
+
+    # Plain mode - one item per line
+    if layout_mode == "plain":
+        return "".join(indent + item + nl for item in items)
+
+    # Calculate available width for content (excluding indent)
+    available_width = width - len(indent)
+    if available_width <= 0:
+        available_width = width
+
+    # Find optimal number of columns
+    max_item_len = max(len(item) for item in items)
+
+    # Start with maximum possible columns and work down
+    best_num_cols = 1
+    best_col_widths: list[int] = []
+
+    for num_cols in range(min(len(items), 20), 0, -1):
+        if layout_mode == "column":
+            # Column mode: fill columns first (items go down, then across)
+            num_rows = (len(items) + num_cols - 1) // num_cols
+        else:  # row mode
+            # Row mode: fill rows first (items go across, then down)
+            num_rows = (len(items) + num_cols - 1) // num_cols
+
+        col_widths: list[int] = []
+
+        if dense:
+            # Calculate width for each column based on its contents
+            for col in range(num_cols):
+                max_width = 0
+                for row in range(num_rows):
+                    if layout_mode == "column":
+                        idx = row + col * num_rows
+                    else:  # row mode
+                        idx = row * num_cols + col
+
+                    if idx < len(items):
+                        max_width = max(max_width, len(items[idx]))
+
+                if max_width > 0:
+                    col_widths.append(max_width)
+        else:
+            # All columns same width (nodense)
+            max_width = 0
+            for col in range(num_cols):
+                for row in range(num_rows):
+                    if layout_mode == "column":
+                        idx = row + col * num_rows
+                    else:  # row mode
+                        idx = row * num_cols + col
+
+                    if idx < len(items):
+                        max_width = max(max_width, len(items[idx]))
+
+            col_widths = [max_width] * num_cols
+
+        # Calculate total width including padding (but not after last column)
+        total_width = sum(col_widths) + padding * (len(col_widths) - 1)
+
+        if total_width <= available_width:
+            best_num_cols = num_cols
+            best_col_widths = col_widths
+            break
+
+    # If no fit found, use single column
+    if not best_col_widths:
+        best_num_cols = 1
+        best_col_widths = [max_item_len]
+
+    # Format output
+    num_rows = (len(items) + best_num_cols - 1) // best_num_cols
+    lines = []
+
+    for row in range(num_rows):
+        line_parts = []
+        for col in range(best_num_cols):
+            if layout_mode == "column":
+                idx = row + col * num_rows
+            else:  # row mode
+                idx = row * best_num_cols + col
+
+            if idx < len(items):
+                item = items[idx]
+                # Pad item to column width, except for last column in row
+                if col < best_num_cols - 1 and col < len(best_col_widths) - 1:
+                    item = item.ljust(best_col_widths[col] + padding)
+                line_parts.append(item)
+
+        if line_parts:
+            lines.append(indent + "".join(line_parts).rstrip() + nl)
+
+    return "".join(lines)
+
+
 class PagerBuffer(BinaryIO):
     """Binary buffer wrapper for Pager to mimic sys.stdout.buffer."""
 
@@ -1616,6 +1758,69 @@ class cmd_stripspace(Command):
         sys.stdout.buffer.write(result)
 
 
+class cmd_column(Command):
+    """Display data in columns."""
+
+    def run(self, args: Sequence[str]) -> None:
+        """Execute the column command.
+
+        Args:
+            args: Command line arguments
+        """
+        parser = argparse.ArgumentParser(
+            description="Format input data into columns for better readability"
+        )
+        parser.add_argument(
+            "--mode",
+            default="column",
+            help=(
+                "Layout mode: 'column' (fill columns first), 'row' (fill rows first), "
+                "'plain' (one column). Add ',dense' for unequal column widths, "
+                "',nodense' for equal widths (default: column)"
+            ),
+        )
+        parser.add_argument(
+            "--width",
+            type=int,
+            help="Terminal width (default: auto-detect)",
+        )
+        parser.add_argument(
+            "--indent",
+            default="",
+            help="String to prepend to each line (default: empty)",
+        )
+        parser.add_argument(
+            "--nl",
+            default="\n",
+            help="String to append to each line, including newline (default: \\n)",
+        )
+        parser.add_argument(
+            "--padding",
+            type=int,
+            default=1,
+            help="Number of spaces between columns (default: 1)",
+        )
+        parsed_args = parser.parse_args(args)
+
+        # Read lines from stdin
+        lines = []
+        for line in sys.stdin:
+            # Strip the newline but keep the content
+            lines.append(line.rstrip("\n\r"))
+
+        # Format and output
+        result = format_columns(
+            lines,
+            width=parsed_args.width,
+            mode=parsed_args.mode,
+            padding=parsed_args.padding,
+            indent=parsed_args.indent,
+            nl=parsed_args.nl,
+        )
+
+        sys.stdout.write(result)
+
+
 class cmd_init(Command):
     """Create an empty Git repository or reinitialize an existing one."""
 
@@ -6137,6 +6342,7 @@ commands = {
     "cherry": cmd_cherry,
     "cherry-pick": cmd_cherry_pick,
     "clone": cmd_clone,
+    "column": cmd_column,
     "commit": cmd_commit,
     "commit-tree": cmd_commit_tree,
     "config": cmd_config,

+ 98 - 0
tests/test_cli.py

@@ -4027,6 +4027,104 @@ class StripspaceCommandTest(DulwichCliTestCase):
         self.assertEqual(stdout, "hello\n\nworld\n")
 
 
+class ColumnCommandTest(DulwichCliTestCase):
+    """Tests for column command."""
+
+    def test_column_mode_default(self):
+        """Test column mode (default) - fills columns first."""
+        old_stdin = sys.stdin
+        try:
+            sys.stdin = io.StringIO("1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n")
+            result, stdout, _stderr = self._run_cli("column", "--width", "40")
+            self.assertIsNone(result)
+            # In column mode, items go down then across
+            # With 12 items and width 40, should fit in multiple columns
+            lines = stdout.strip().split("\n")
+            # First line should start with "1"
+            self.assertTrue(lines[0].startswith("1"))
+        finally:
+            sys.stdin = old_stdin
+
+    def test_column_mode_row(self):
+        """Test row mode - fills rows first."""
+        old_stdin = sys.stdin
+        try:
+            sys.stdin = io.StringIO("1\n2\n3\n4\n5\n6\n")
+            result, stdout, _stderr = self._run_cli(
+                "column", "--mode", "row", "--width", "40"
+            )
+            self.assertIsNone(result)
+            # In row mode, items go across then down
+            # Should have items 1, 2, 3... on first line
+            lines = stdout.strip().split("\n")
+            self.assertTrue("1" in lines[0])
+            self.assertTrue("2" in lines[0])
+        finally:
+            sys.stdin = old_stdin
+
+    def test_column_mode_plain(self):
+        """Test plain mode - one item per line."""
+        old_stdin = sys.stdin
+        try:
+            sys.stdin = io.StringIO("apple\nbanana\ncherry\n")
+            result, stdout, _stderr = self._run_cli("column", "--mode", "plain")
+            self.assertIsNone(result)
+            self.assertEqual(stdout, "apple\nbanana\ncherry\n")
+        finally:
+            sys.stdin = old_stdin
+
+    def test_column_padding(self):
+        """Test custom padding between columns."""
+        old_stdin = sys.stdin
+        try:
+            sys.stdin = io.StringIO("a\nb\nc\nd\ne\nf\n")
+            result, stdout, _stderr = self._run_cli(
+                "column", "--mode", "row", "--padding", "5", "--width", "80"
+            )
+            self.assertIsNone(result)
+            # With padding=5, should have 5 spaces between items
+            self.assertIn("     ", stdout)
+        finally:
+            sys.stdin = old_stdin
+
+    def test_column_indent(self):
+        """Test indent prepended to each line."""
+        old_stdin = sys.stdin
+        try:
+            sys.stdin = io.StringIO("apple\nbanana\n")
+            result, stdout, _stderr = self._run_cli(
+                "column", "--mode", "plain", "--indent", "  "
+            )
+            self.assertIsNone(result)
+            lines = stdout.split("\n")
+            self.assertTrue(lines[0].startswith("  apple"))
+            self.assertTrue(lines[1].startswith("  banana"))
+        finally:
+            sys.stdin = old_stdin
+
+    def test_column_empty_input(self):
+        """Test with empty input."""
+        old_stdin = sys.stdin
+        try:
+            sys.stdin = io.StringIO("")
+            result, stdout, _stderr = self._run_cli("column")
+            self.assertIsNone(result)
+            self.assertEqual(stdout, "")
+        finally:
+            sys.stdin = old_stdin
+
+    def test_column_single_item(self):
+        """Test with single item."""
+        old_stdin = sys.stdin
+        try:
+            sys.stdin = io.StringIO("single\n")
+            result, stdout, _stderr = self._run_cli("column")
+            self.assertIsNone(result)
+            self.assertEqual(stdout, "single\n")
+        finally:
+            sys.stdin = old_stdin
+
+
 class MailinfoCommandTests(DulwichCliTestCase):
     """Tests for the mailinfo command."""