procclean 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- procclean/__init__.py +11 -0
- procclean/__main__.py +22 -0
- procclean/cli/__init__.py +27 -0
- procclean/cli/commands.py +213 -0
- procclean/cli/docs.py +234 -0
- procclean/cli/parser.py +272 -0
- procclean/core/__init__.py +47 -0
- procclean/core/actions.py +46 -0
- procclean/core/constants.py +48 -0
- procclean/core/filters.py +121 -0
- procclean/core/memory.py +22 -0
- procclean/core/models.py +27 -0
- procclean/core/process.py +160 -0
- procclean/formatters/__init__.py +33 -0
- procclean/formatters/columns.py +128 -0
- procclean/formatters/output.py +158 -0
- procclean/tui/__init__.py +6 -0
- procclean/tui/app.py +401 -0
- procclean/tui/app.tcss +87 -0
- procclean/tui/screens.py +79 -0
- procclean-1.2.0.dist-info/METADATA +164 -0
- procclean-1.2.0.dist-info/RECORD +24 -0
- procclean-1.2.0.dist-info/WHEEL +4 -0
- procclean-1.2.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Process listing and grouping utilities."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import psutil
|
|
7
|
+
|
|
8
|
+
from .models import ProcessInfo
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_tmux_env(pid: int) -> bool:
|
|
12
|
+
"""Check whether the process has a TMUX environment variable.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
pid: Process ID.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
True if the process environment contains ``TMUX=``, otherwise False.
|
|
19
|
+
"""
|
|
20
|
+
try:
|
|
21
|
+
environ_path = Path(f"/proc/{pid}/environ")
|
|
22
|
+
if environ_path.exists():
|
|
23
|
+
environ = environ_path.read_bytes().decode("utf-8", errors="ignore")
|
|
24
|
+
return "TMUX=" in environ
|
|
25
|
+
except (PermissionError, FileNotFoundError, ProcessLookupError):
|
|
26
|
+
pass
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_cwd(pid: int) -> str:
|
|
31
|
+
"""Get process working directory.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
pid: Process ID.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
The resolved current working directory for the process, or "?" if it
|
|
38
|
+
cannot be determined due to permissions or the process no longer
|
|
39
|
+
existing.
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
return str(Path(f"/proc/{pid}/cwd").readlink())
|
|
43
|
+
except (PermissionError, FileNotFoundError, ProcessLookupError):
|
|
44
|
+
return "?"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_process_list(
|
|
48
|
+
sort_by: str = "memory",
|
|
49
|
+
filter_user: str | None = None,
|
|
50
|
+
min_memory_mb: float = 10.0,
|
|
51
|
+
) -> list[ProcessInfo]:
|
|
52
|
+
"""Get list of processes with detailed info.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
sort_by: Field to sort by ("memory", "cpu", or "name").
|
|
56
|
+
filter_user: Only include processes owned by this user. Defaults to the
|
|
57
|
+
current user.
|
|
58
|
+
min_memory_mb: Minimum RSS (in MB) for a process to be included.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
A list of ProcessInfo entries matching the filters, sorted by ``sort_by``.
|
|
62
|
+
"""
|
|
63
|
+
processes = []
|
|
64
|
+
current_user = os.getlogin()
|
|
65
|
+
filter_user = filter_user or current_user
|
|
66
|
+
|
|
67
|
+
for proc in psutil.process_iter([
|
|
68
|
+
"pid",
|
|
69
|
+
"name",
|
|
70
|
+
"cmdline",
|
|
71
|
+
"ppid",
|
|
72
|
+
"memory_info",
|
|
73
|
+
"cpu_percent",
|
|
74
|
+
"username",
|
|
75
|
+
"create_time",
|
|
76
|
+
"status",
|
|
77
|
+
]):
|
|
78
|
+
try:
|
|
79
|
+
info = proc.info
|
|
80
|
+
if info["username"] != filter_user:
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
rss_mb = (
|
|
84
|
+
(info["memory_info"].rss / 1024 / 1024) if info["memory_info"] else 0
|
|
85
|
+
)
|
|
86
|
+
if rss_mb < min_memory_mb:
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
ppid = info["ppid"] or 0
|
|
90
|
+
try:
|
|
91
|
+
parent = psutil.Process(ppid)
|
|
92
|
+
parent_name = parent.name()
|
|
93
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
94
|
+
parent_name = "?"
|
|
95
|
+
|
|
96
|
+
# Check if orphaned (parent is init/systemd)
|
|
97
|
+
is_orphan = ppid == 1 or parent_name in {"systemd", "init"}
|
|
98
|
+
|
|
99
|
+
cmdline = " ".join(info["cmdline"] or [])[:200]
|
|
100
|
+
if not cmdline:
|
|
101
|
+
cmdline = info["name"]
|
|
102
|
+
|
|
103
|
+
processes.append(
|
|
104
|
+
ProcessInfo(
|
|
105
|
+
pid=info["pid"],
|
|
106
|
+
name=info["name"],
|
|
107
|
+
cmdline=cmdline,
|
|
108
|
+
cwd=get_cwd(info["pid"]),
|
|
109
|
+
ppid=ppid,
|
|
110
|
+
parent_name=parent_name,
|
|
111
|
+
rss_mb=rss_mb,
|
|
112
|
+
cpu_percent=info["cpu_percent"] or 0,
|
|
113
|
+
username=info["username"],
|
|
114
|
+
create_time=info["create_time"] or 0,
|
|
115
|
+
is_orphan=is_orphan,
|
|
116
|
+
in_tmux=get_tmux_env(info["pid"]) if is_orphan else False,
|
|
117
|
+
status=info["status"] or "?",
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
if sort_by == "memory":
|
|
124
|
+
processes.sort(key=lambda p: p.rss_mb, reverse=True)
|
|
125
|
+
elif sort_by == "cpu":
|
|
126
|
+
processes.sort(key=lambda p: p.cpu_percent, reverse=True)
|
|
127
|
+
elif sort_by == "name":
|
|
128
|
+
processes.sort(key=lambda p: p.name.lower())
|
|
129
|
+
|
|
130
|
+
return processes
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def find_similar_processes(
|
|
134
|
+
processes: list[ProcessInfo],
|
|
135
|
+
) -> dict[str, list[ProcessInfo]]:
|
|
136
|
+
"""Group processes by similar command patterns.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
processes: Processes to group.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
A mapping of group keys (normalized executable/command names) to the list
|
|
143
|
+
of processes in that group. Only groups containing more than one process
|
|
144
|
+
are returned.
|
|
145
|
+
"""
|
|
146
|
+
groups: dict[str, list[ProcessInfo]] = {}
|
|
147
|
+
|
|
148
|
+
for proc in processes:
|
|
149
|
+
# Extract key identifier from cmdline
|
|
150
|
+
cmd = proc.cmdline.split()[0] if proc.cmdline else proc.name
|
|
151
|
+
# Normalize paths
|
|
152
|
+
if "/" in cmd:
|
|
153
|
+
cmd = cmd.split("/")[-1]
|
|
154
|
+
|
|
155
|
+
if cmd not in groups:
|
|
156
|
+
groups[cmd] = []
|
|
157
|
+
groups[cmd].append(proc)
|
|
158
|
+
|
|
159
|
+
# Only return groups with multiple processes
|
|
160
|
+
return {k: v for k, v in groups.items() if len(v) > 1}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Output formatters for process data."""
|
|
2
|
+
|
|
3
|
+
from .columns import (
|
|
4
|
+
COLUMNS,
|
|
5
|
+
DEFAULT_COLUMNS,
|
|
6
|
+
ClipSide,
|
|
7
|
+
ColumnSpec,
|
|
8
|
+
clip,
|
|
9
|
+
get_available_columns,
|
|
10
|
+
)
|
|
11
|
+
from .output import (
|
|
12
|
+
format_csv,
|
|
13
|
+
format_json,
|
|
14
|
+
format_markdown,
|
|
15
|
+
format_output,
|
|
16
|
+
format_table,
|
|
17
|
+
get_rows,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"COLUMNS",
|
|
22
|
+
"DEFAULT_COLUMNS",
|
|
23
|
+
"ClipSide",
|
|
24
|
+
"ColumnSpec",
|
|
25
|
+
"clip",
|
|
26
|
+
"format_csv",
|
|
27
|
+
"format_json",
|
|
28
|
+
"format_markdown",
|
|
29
|
+
"format_output",
|
|
30
|
+
"format_table",
|
|
31
|
+
"get_available_columns",
|
|
32
|
+
"get_rows",
|
|
33
|
+
]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Column specifications for process tables."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass, replace
|
|
5
|
+
from enum import StrEnum, auto
|
|
6
|
+
from typing import Self
|
|
7
|
+
|
|
8
|
+
from procclean.core import ProcessInfo
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ClipSide(StrEnum):
|
|
12
|
+
"""Which side to truncate when clipping."""
|
|
13
|
+
|
|
14
|
+
LEFT = auto() # Keep right portion (good for paths)
|
|
15
|
+
RIGHT = auto() # Keep left portion (good for names)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def clip(s: str, max_len: int, side: ClipSide = ClipSide.RIGHT) -> str:
|
|
19
|
+
"""Truncate string, adding ellipsis on clipped side.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
s: Input string to potentially clip.
|
|
23
|
+
max_len: Maximum length of returned string, including ellipsis.
|
|
24
|
+
side: Which side to truncate when clipping.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
The original string if it fits within ``max_len``; otherwise a clipped
|
|
28
|
+
version with an ellipsis on the clipped side.
|
|
29
|
+
"""
|
|
30
|
+
if len(s) <= max_len:
|
|
31
|
+
return s
|
|
32
|
+
match side:
|
|
33
|
+
case ClipSide.LEFT:
|
|
34
|
+
return f"...{s[-(max_len - 3) :]}"
|
|
35
|
+
case ClipSide.RIGHT:
|
|
36
|
+
return f"{s[: max_len - 3]}..."
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class ColumnSpec[T]:
|
|
41
|
+
"""Specification for a table column."""
|
|
42
|
+
|
|
43
|
+
key: str
|
|
44
|
+
header: str
|
|
45
|
+
get: Callable[[ProcessInfo], T]
|
|
46
|
+
fmt: Callable[[T], str] = str
|
|
47
|
+
max_width: int | None = None
|
|
48
|
+
clip_side: ClipSide = ClipSide.RIGHT
|
|
49
|
+
|
|
50
|
+
def extract(self, proc: ProcessInfo) -> str:
|
|
51
|
+
"""Extract and format value from a process.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
proc: Process to extract the value from.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
The formatted value, clipped with an ellipsis if it exceeds
|
|
58
|
+
``max_width``.
|
|
59
|
+
"""
|
|
60
|
+
formatted = self.fmt(self.get(proc))
|
|
61
|
+
if self.max_width and len(formatted) > self.max_width:
|
|
62
|
+
return clip(formatted, self.max_width, self.clip_side)
|
|
63
|
+
return formatted
|
|
64
|
+
|
|
65
|
+
def with_width(self, width: int, side: ClipSide = ClipSide.RIGHT) -> Self:
|
|
66
|
+
"""Return a copy with specified max width and clip side.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
width: Maximum width for the column output.
|
|
70
|
+
side: Which side to truncate when clipping.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
A new ``ColumnSpec`` with ``max_width`` and ``clip_side`` set.
|
|
74
|
+
"""
|
|
75
|
+
return replace(self, max_width=width, clip_side=side)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _fmt_float1(v: float) -> str:
|
|
79
|
+
return f"{v:.1f}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _fmt_status(p: ProcessInfo) -> str:
|
|
83
|
+
parts = [p.status]
|
|
84
|
+
if p.is_orphan:
|
|
85
|
+
parts.append("[orphan]")
|
|
86
|
+
if p.in_tmux:
|
|
87
|
+
parts.append("[tmux]")
|
|
88
|
+
return " ".join(parts)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# Column definitions
|
|
92
|
+
COLUMNS: dict[str, ColumnSpec] = {
|
|
93
|
+
"pid": ColumnSpec("pid", "PID", lambda p: p.pid),
|
|
94
|
+
"name": ColumnSpec("name", "Name", lambda p: p.name, max_width=25),
|
|
95
|
+
"rss_mb": ColumnSpec("rss_mb", "RAM (MB)", lambda p: p.rss_mb, _fmt_float1),
|
|
96
|
+
"cpu_percent": ColumnSpec(
|
|
97
|
+
"cpu_percent", "CPU%", lambda p: p.cpu_percent, _fmt_float1
|
|
98
|
+
),
|
|
99
|
+
"cwd": ColumnSpec(
|
|
100
|
+
"cwd", "CWD", lambda p: p.cwd, max_width=40, clip_side=ClipSide.LEFT
|
|
101
|
+
),
|
|
102
|
+
"ppid": ColumnSpec("ppid", "PPID", lambda p: p.ppid),
|
|
103
|
+
"parent_name": ColumnSpec(
|
|
104
|
+
"parent_name", "Parent", lambda p: p.parent_name, max_width=15
|
|
105
|
+
),
|
|
106
|
+
"status": ColumnSpec("status", "Status", lambda p: p, _fmt_status),
|
|
107
|
+
"cmdline": ColumnSpec("cmdline", "Command", lambda p: p.cmdline, max_width=60),
|
|
108
|
+
"username": ColumnSpec("username", "User", lambda p: p.username),
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
DEFAULT_COLUMNS: tuple[str, ...] = (
|
|
112
|
+
"pid",
|
|
113
|
+
"name",
|
|
114
|
+
"rss_mb",
|
|
115
|
+
"cpu_percent",
|
|
116
|
+
"cwd",
|
|
117
|
+
"ppid",
|
|
118
|
+
"status",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def get_available_columns() -> list[str]:
|
|
123
|
+
"""Return list of available column keys.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
A list of keys for all available columns.
|
|
127
|
+
"""
|
|
128
|
+
return list(COLUMNS)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Output format functions for process data."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from dataclasses import asdict, fields
|
|
8
|
+
|
|
9
|
+
from tabulate import tabulate
|
|
10
|
+
|
|
11
|
+
from procclean.core import ProcessInfo
|
|
12
|
+
|
|
13
|
+
from .columns import COLUMNS, DEFAULT_COLUMNS
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_rows(
|
|
17
|
+
procs: list[ProcessInfo],
|
|
18
|
+
columns: Sequence[str] | None = None,
|
|
19
|
+
) -> tuple[list[str], list[list[str]]]:
|
|
20
|
+
"""Extract headers and formatted rows from processes.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
procs: Processes to extract rows from.
|
|
24
|
+
columns: Optional ordered list of column keys to include.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
A tuple of (headers, rows), where headers is a list of column headers and
|
|
28
|
+
rows is a list of formatted string rows.
|
|
29
|
+
"""
|
|
30
|
+
cols = columns or DEFAULT_COLUMNS
|
|
31
|
+
specs = [COLUMNS[c] for c in cols if c in COLUMNS]
|
|
32
|
+
headers = [s.header for s in specs]
|
|
33
|
+
rows = [[s.extract(p) for s in specs] for p in procs]
|
|
34
|
+
return headers, rows
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def format_table(
|
|
38
|
+
procs: list[ProcessInfo],
|
|
39
|
+
columns: Sequence[str] | None = None,
|
|
40
|
+
) -> str:
|
|
41
|
+
"""Format processes as ASCII table.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
procs: Processes to format.
|
|
45
|
+
columns: Optional ordered list of column keys to include.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
A formatted ASCII table string, or a message if no processes are found.
|
|
49
|
+
"""
|
|
50
|
+
if not procs:
|
|
51
|
+
return "No processes found."
|
|
52
|
+
headers, rows = get_rows(procs, columns)
|
|
53
|
+
return tabulate(rows, headers=headers, tablefmt="simple_outline")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def format_markdown(
|
|
57
|
+
procs: list[ProcessInfo],
|
|
58
|
+
columns: Sequence[str] | None = None,
|
|
59
|
+
) -> str:
|
|
60
|
+
"""Format processes as a GitHub-flavored Markdown table.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
procs: Processes to format.
|
|
64
|
+
columns: Optional ordered list of column keys to include.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
A formatted Markdown table string, or a message if no processes are found.
|
|
68
|
+
"""
|
|
69
|
+
if not procs:
|
|
70
|
+
return "No processes found."
|
|
71
|
+
headers, rows = get_rows(procs, columns)
|
|
72
|
+
return tabulate(rows, headers=headers, tablefmt="pipe")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _serialize_process(p: ProcessInfo) -> dict:
|
|
76
|
+
"""Convert a process to a JSON-serializable dictionary.
|
|
77
|
+
|
|
78
|
+
Float values are rounded to 2 decimal places for stable output.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
p: Process to serialize.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
A JSON-serializable dict representation of the process.
|
|
85
|
+
"""
|
|
86
|
+
data = asdict(p)
|
|
87
|
+
data["rss_mb"] = round(data["rss_mb"], 2)
|
|
88
|
+
data["cpu_percent"] = round(data["cpu_percent"], 2)
|
|
89
|
+
return data
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def format_json(procs: list[ProcessInfo]) -> str:
|
|
93
|
+
"""Format processes as JSON.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
procs: Processes to format.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
A pretty-printed JSON string representing the processes.
|
|
100
|
+
"""
|
|
101
|
+
return json.dumps([_serialize_process(p) for p in procs], indent=2)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def format_csv(procs: list[ProcessInfo]) -> str:
|
|
105
|
+
"""Format processes as CSV.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
procs: Processes to format.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
A CSV string representing the processes. If no processes are provided,
|
|
112
|
+
returns an empty string.
|
|
113
|
+
"""
|
|
114
|
+
if not procs:
|
|
115
|
+
return ""
|
|
116
|
+
output = io.StringIO()
|
|
117
|
+
writer = csv.writer(output)
|
|
118
|
+
|
|
119
|
+
# Get field names from dataclass
|
|
120
|
+
fieldnames = [f.name for f in fields(ProcessInfo)]
|
|
121
|
+
writer.writerow(fieldnames)
|
|
122
|
+
|
|
123
|
+
for p in procs:
|
|
124
|
+
row = []
|
|
125
|
+
for name in fieldnames:
|
|
126
|
+
val = getattr(p, name)
|
|
127
|
+
if isinstance(val, float):
|
|
128
|
+
val = f"{val:.2f}"
|
|
129
|
+
row.append(val)
|
|
130
|
+
writer.writerow(row)
|
|
131
|
+
|
|
132
|
+
return output.getvalue()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def format_output(
|
|
136
|
+
procs: list[ProcessInfo],
|
|
137
|
+
fmt: str,
|
|
138
|
+
columns: Sequence[str] | None = None,
|
|
139
|
+
) -> str:
|
|
140
|
+
"""Format processes in the requested format.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
procs: Processes to format.
|
|
144
|
+
fmt: Output format key (e.g., "json", "csv", "md"/"markdown").
|
|
145
|
+
columns: Optional ordered list of column keys to include (table/markdown).
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
The formatted output string.
|
|
149
|
+
"""
|
|
150
|
+
match fmt:
|
|
151
|
+
case "json":
|
|
152
|
+
return format_json(procs)
|
|
153
|
+
case "csv":
|
|
154
|
+
return format_csv(procs)
|
|
155
|
+
case "md" | "markdown":
|
|
156
|
+
return format_markdown(procs, columns)
|
|
157
|
+
case _:
|
|
158
|
+
return format_table(procs, columns)
|