Skip to content

Commit c2740e9

Browse files
committed
refactor: separate entities
1 parent 55e52be commit c2740e9

File tree

12 files changed

+583
-476
lines changed

12 files changed

+583
-476
lines changed

killpy/__init__.py

Whitespace-only changes.

killpy/__main__.py

Lines changed: 1 addition & 476 deletions
Large diffs are not rendered by default.

killpy/cleaners/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import shutil
2+
from pathlib import Path
3+
4+
from killpy.files import get_total_size
5+
6+
7+
def remove_pycache(path: Path) -> int:
8+
total_freed_space = 0
9+
for pycache_dir in path.rglob("__pycache__"):
10+
try:
11+
total_freed_space += get_total_size(pycache_dir)
12+
shutil.rmtree(pycache_dir)
13+
except Exception:
14+
continue
15+
return total_freed_space

killpy/cli.py

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
import asyncio
2+
from enum import Enum
3+
from pathlib import Path
4+
5+
from textual.app import App, ComposeResult
6+
from textual.binding import Binding
7+
from textual.color import Gradient
8+
from textual.coordinate import Coordinate
9+
from textual.widgets import (
10+
DataTable,
11+
Footer,
12+
Header,
13+
Label,
14+
ProgressBar,
15+
Static,
16+
TabbedContent,
17+
TabPane,
18+
)
19+
20+
from killpy.cleaners import remove_pycache
21+
from killpy.files import format_size
22+
from killpy.killers import (
23+
CondaKiller,
24+
PipxKiller,
25+
PoetryKiller,
26+
PyenvKiller,
27+
VenvKiller,
28+
)
29+
30+
31+
def is_venv_tab(func):
32+
def wrapper(self, *args, **kwargs):
33+
if self.query_one(TabbedContent).active == "venv-tab":
34+
return func(self, *args, **kwargs)
35+
36+
return wrapper
37+
38+
39+
def is_pipx_tab(func):
40+
def wrapper(self, *args, **kwargs):
41+
if self.query_one(TabbedContent).active == "pipx-tab":
42+
return func(self, *args, **kwargs)
43+
44+
return wrapper
45+
46+
47+
def remove_duplicates(venvs):
48+
seen_paths = set()
49+
unique_venvs = []
50+
51+
for venv in venvs:
52+
venv_path = venv[0]
53+
if venv_path not in seen_paths:
54+
unique_venvs.append(venv)
55+
seen_paths.add(venv_path)
56+
57+
return unique_venvs
58+
59+
60+
class EnvStatus(Enum):
61+
DELETED = "DELETED"
62+
MARKED_TO_DELETE = "MARKED TO DELETE"
63+
64+
65+
class TableApp(App):
66+
deleted_cells: Coordinate = []
67+
bytes_release: int = 0
68+
69+
killers = {
70+
"conda_killer": CondaKiller(),
71+
"pipx_killer": PipxKiller(),
72+
"poetry_killer": PoetryKiller(Path.cwd()),
73+
"venv_killer": VenvKiller(Path.cwd()),
74+
"pyenv_killer": PyenvKiller(Path.cwd()),
75+
}
76+
77+
BINDINGS = [
78+
Binding(key="ctrl+q", action="quit", description="Exit"),
79+
Binding(
80+
key="d",
81+
action="mark_for_delete",
82+
description="Mark for deletion",
83+
show=True,
84+
),
85+
Binding(
86+
key="ctrl+d",
87+
action="confirm_delete",
88+
description="Delete marked",
89+
show=True,
90+
),
91+
Binding(
92+
key="shift+delete",
93+
action="delete_now",
94+
description="Delete immediately",
95+
show=True,
96+
),
97+
Binding(
98+
key="p",
99+
action="clean_pycache",
100+
description="Clean __pycache__ dirs",
101+
show=True,
102+
),
103+
Binding(
104+
key="u",
105+
action="uninstall_pipx",
106+
description="Uninstall pipx packages",
107+
show=True,
108+
),
109+
]
110+
111+
CSS = """
112+
#banner {
113+
color: white;
114+
border: heavy green;
115+
}
116+
117+
TabbedContent #--content-tab-venv-tab {
118+
color: green;
119+
}
120+
121+
TabbedContent #--content-tab-pipx-tab {
122+
color: yellow;
123+
}
124+
"""
125+
126+
def compose(self) -> ComposeResult:
127+
yield Header()
128+
banner = Static(
129+
"""
130+
█ ▄ ▄ █ █ ▄▄▄▄ ▄ ▄ ____
131+
█▄▀ ▄ █ █ █ █ █ █ .'`_ o `;__,
132+
█ ▀▄ █ █ █ █▄▄▄▀ ▀▀▀█ . .'.'` '---' ' A tool to delete
133+
█ █ █ █ █ █ ▄ █ .`-...-'.' .venv, Conda, Poetry environments
134+
▀ ▀▀▀ `-...-'and clean up __pycache__ and temp files.
135+
""",
136+
id="banner",
137+
)
138+
yield banner
139+
yield Label("Searching for virtual environments...")
140+
141+
gradient = Gradient.from_colors(
142+
"#881177",
143+
"#aa3355",
144+
"#cc6666",
145+
"#ee9944",
146+
"#eedd00",
147+
"#99dd55",
148+
"#44dd88",
149+
"#22ccbb",
150+
"#00bbcc",
151+
"#0099cc",
152+
"#3366bb",
153+
"#663399",
154+
)
155+
yield ProgressBar(total=100, gradient=gradient, show_eta=False)
156+
157+
with TabbedContent():
158+
with TabPane("Virtual Env", id="venv-tab"):
159+
yield DataTable(id="venv-table")
160+
with TabPane("Pipx", id="pipx-tab"):
161+
yield DataTable(id="pipx-table")
162+
163+
yield Footer(show_command_palette=False)
164+
165+
async def on_mount(self) -> None:
166+
self.title = """killpy"""
167+
await self.find_venvs()
168+
await self.find_pipx()
169+
170+
def list_environments_of(self, killer: str):
171+
return asyncio.to_thread(self.killers[killer].list_environments)
172+
173+
async def find_venvs(self):
174+
venvs = await asyncio.gather(
175+
self.list_environments_of("venv_killer"),
176+
self.list_environments_of("conda_killer"),
177+
self.list_environments_of("pyenv_killer"),
178+
self.list_environments_of("poetry_killer"),
179+
)
180+
venvs = [env for sublist in venvs for env in sublist]
181+
venvs = remove_duplicates(venvs)
182+
183+
table = self.query_one("#venv-table", DataTable)
184+
table.focus()
185+
table.add_columns(
186+
"Path", "Type", "Last Modified", "Size", "Size (Human Readable)", "Status"
187+
)
188+
189+
for venv in venvs:
190+
table.add_row(*venv)
191+
192+
table.cursor_type = "row"
193+
table.zebra_stripes = True
194+
195+
self.query_one(Label).update(f"Found {len(venvs)} .venv directories")
196+
197+
async def find_pipx(self):
198+
venvs = await asyncio.gather(self.list_environments_of("pipx_killer"))
199+
200+
venvs = [env for sublist in venvs for env in sublist]
201+
202+
table = self.query_one("#pipx-table", DataTable)
203+
table.focus()
204+
table.add_columns("Package", "Size", "Size (Human Readable)", "Status")
205+
206+
for venv in venvs:
207+
table.add_row(*venv)
208+
209+
table.cursor_type = "row"
210+
table.zebra_stripes = True
211+
212+
self.query_one(Label).update(f"Found {len(venvs)} .venv directories")
213+
214+
async def action_clean_pycache(self):
215+
current_directory = Path.cwd()
216+
total_freed_space = await asyncio.to_thread(remove_pycache, current_directory)
217+
self.bytes_release += total_freed_space
218+
self.query_one(Label).update(f"{format_size(self.bytes_release)} deleted")
219+
self.bell()
220+
221+
@is_venv_tab
222+
def action_confirm_delete(self):
223+
table = self.query_one("#venv-table", DataTable)
224+
for row_index in range(table.row_count):
225+
row_data = table.get_row_at(row_index)
226+
current_status = row_data[5]
227+
if current_status == EnvStatus.MARKED_TO_DELETE.value:
228+
cursor_cell = Coordinate(row_index, 0)
229+
if cursor_cell not in self.deleted_cells:
230+
path = row_data[0]
231+
self.bytes_release += row_data[3]
232+
env_type = row_data[1]
233+
self.delete_environment(path, env_type)
234+
table.update_cell_at((row_index, 5), EnvStatus.DELETED.value)
235+
self.deleted_cells.append(cursor_cell)
236+
self.query_one(Label).update(f"{format_size(self.bytes_release)} deleted")
237+
self.bell()
238+
239+
@is_venv_tab
240+
def action_mark_for_delete(self):
241+
table = self.query_one("#venv-table", DataTable)
242+
243+
cursor_cell = table.cursor_coordinate
244+
if cursor_cell:
245+
row_data = table.get_row_at(cursor_cell.row)
246+
current_status = row_data[5]
247+
if current_status == EnvStatus.DELETED.value:
248+
return
249+
elif current_status == EnvStatus.MARKED_TO_DELETE.value:
250+
table.update_cell_at((cursor_cell.row, 5), "")
251+
else:
252+
table.update_cell_at(
253+
(cursor_cell.row, 5), EnvStatus.MARKED_TO_DELETE.value
254+
)
255+
256+
@is_venv_tab
257+
def action_delete_now(self):
258+
table = self.query_one("#venv-table", DataTable)
259+
cursor_cell = table.cursor_coordinate
260+
if cursor_cell:
261+
if cursor_cell in self.deleted_cells:
262+
return
263+
row_data = table.get_row_at(cursor_cell.row)
264+
path = row_data[0]
265+
self.bytes_release += row_data[3]
266+
env_type = row_data[1]
267+
self.delete_environment(path, env_type)
268+
table.update_cell_at((cursor_cell.row, 5), EnvStatus.DELETED.value)
269+
self.query_one(Label).update(f"{format_size(self.bytes_release)} deleted")
270+
self.deleted_cells.append(cursor_cell)
271+
self.bell()
272+
273+
@is_venv_tab
274+
def delete_environment(self, path, env_type):
275+
if env_type in {".venv", "pyvenv.cfg", "poetry"}:
276+
self.poetry_killer.remove_environment(path) # TODO refactor this
277+
else:
278+
self.conda_killer.remove_environment(path)
279+
280+
@is_pipx_tab
281+
def action_uninstall_pipx(self):
282+
table = self.query_one("#pipx-table", DataTable)
283+
cursor_cell = table.cursor_coordinate
284+
if cursor_cell:
285+
if cursor_cell in self.deleted_cells:
286+
return
287+
row_data = table.get_row_at(cursor_cell.row)
288+
package = row_data[0]
289+
size = row_data[1]
290+
291+
self.pipx_killer.remove_environment(package)
292+
293+
table.update_cell_at((cursor_cell.row, 3), EnvStatus.DELETED.value)
294+
self.deleted_cells.append(cursor_cell)
295+
self.bytes_release += size
296+
self.query_one(Label).update(f"{format_size(self.bytes_release)} deleted")
297+
298+
self.bell()

killpy/files/__init__.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from pathlib import Path
2+
3+
4+
def get_total_size(path: Path) -> int:
5+
total_size = 0
6+
for f in path.rglob("*"):
7+
try:
8+
if f.is_file():
9+
total_size += f.stat().st_size
10+
except FileNotFoundError:
11+
continue
12+
return total_size
13+
14+
15+
def format_size(size_in_bytes: int):
16+
if size_in_bytes >= 1 << 30:
17+
return f"{size_in_bytes / (1 << 30):.2f} GB"
18+
elif size_in_bytes >= 1 << 20:
19+
return f"{size_in_bytes / (1 << 20):.2f} MB"
20+
elif size_in_bytes >= 1 << 10:
21+
return f"{size_in_bytes / (1 << 10):.2f} KB"
22+
else:
23+
return f"{size_in_bytes} bytes"

killpy/killers/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from killpy.killers.conda_killer import CondaKiller
2+
from killpy.killers.pipx_killer import PipxKiller
3+
from killpy.killers.poetry_killer import PoetryKiller
4+
from killpy.killers.pyenv_killer import PyenvKiller
5+
from killpy.killers.venv_killer import VenvKiller
6+
7+
__all__ = ["CondaKiller", "PipxKiller", "PoetryKiller", "PyenvKiller", "VenvKiller"]

0 commit comments

Comments
 (0)