venvcleaner 0.1.0__tar.gz

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.
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2025 yamakox (Koichi Yamamoto)
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: venvcleaner
3
+ Version: 0.1.0
4
+ Summary: A GUI tool for cleaning up Python virtual environments (venv directories).
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Author: yamakox
8
+ Author-email: notolog.tech@gmail.com
9
+ Requires-Python: >=3.11
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Development Status :: 4 - Beta
14
+ Requires-Dist: click (>=8.3.0)
15
+ Requires-Dist: wxpython (>=4.2.3)
16
+ Project-URL: Homepage, https://github.com/yamakox/VenvCleaner
17
+ Project-URL: Issues, https://github.com/yamakox/VenvCleaner/issues
18
+ Project-URL: Repository, https://github.com/yamakox/VenvCleaner.git
19
+ Description-Content-Type: text/markdown
20
+
21
+ # Venv Cleaner
22
+
23
+ A simple GUI tool for cleaning up old or unused Python virtual environments (`venv` directories).
24
+
25
+ ## How to Use
26
+
27
+ The easiest way to run Venv Cleaner is with [uvx](https://docs.astral.sh/uv/guides/tools/):
28
+
29
+ ```bash
30
+ uvx venvcleaner
31
+ ```
32
+
33
+ You can also specify a target directory:
34
+
35
+ ```bash
36
+ uvx venvcleaner /path/to/target-directory
37
+ ```
38
+
39
+ ![screenshot](./screenshot.png)
40
+
41
+ ## Features
42
+
43
+ - Scans the target directory for virtual environments (`.venv` folders containing a `pyvenv.cfg` file).
44
+ - You can change or refresh the target directory with the __Select...__ or __Refresh__ buttons.
45
+ - Choose which venvs to clean using the selection list.
46
+ - __Select All__ selects all detected venvs.
47
+ - __Select None__ clears the selection.
48
+ - __Copy Paths__ copies the paths of selected venvs to your clipboard.
49
+ You can paste them into a terminal to run shell commands manually. For example:
50
+
51
+ ```bash
52
+ ls /path/to/project-1/.venv "/path/to/project 2/.venv"
53
+ rm -r /path/to/project-1/.venv "/path/to/project 2/.venv"
54
+ ```
55
+
56
+ - __Cleanup Venvs__ deletes the selected venv directories.
57
+ Before using this button, you must check the agreement box:
58
+
59
+ > I agree to take responsibility for my actions.
60
+
61
+ ## License
62
+
63
+ This software is distributed under the terms of the [MIT License](./LICENSE).
64
+
@@ -0,0 +1,43 @@
1
+ # Venv Cleaner
2
+
3
+ A simple GUI tool for cleaning up old or unused Python virtual environments (`venv` directories).
4
+
5
+ ## How to Use
6
+
7
+ The easiest way to run Venv Cleaner is with [uvx](https://docs.astral.sh/uv/guides/tools/):
8
+
9
+ ```bash
10
+ uvx venvcleaner
11
+ ```
12
+
13
+ You can also specify a target directory:
14
+
15
+ ```bash
16
+ uvx venvcleaner /path/to/target-directory
17
+ ```
18
+
19
+ ![screenshot](./screenshot.png)
20
+
21
+ ## Features
22
+
23
+ - Scans the target directory for virtual environments (`.venv` folders containing a `pyvenv.cfg` file).
24
+ - You can change or refresh the target directory with the __Select...__ or __Refresh__ buttons.
25
+ - Choose which venvs to clean using the selection list.
26
+ - __Select All__ selects all detected venvs.
27
+ - __Select None__ clears the selection.
28
+ - __Copy Paths__ copies the paths of selected venvs to your clipboard.
29
+ You can paste them into a terminal to run shell commands manually. For example:
30
+
31
+ ```bash
32
+ ls /path/to/project-1/.venv "/path/to/project 2/.venv"
33
+ rm -r /path/to/project-1/.venv "/path/to/project 2/.venv"
34
+ ```
35
+
36
+ - __Cleanup Venvs__ deletes the selected venv directories.
37
+ Before using this button, you must check the agreement box:
38
+
39
+ > I agree to take responsibility for my actions.
40
+
41
+ ## License
42
+
43
+ This software is distributed under the terms of the [MIT License](./LICENSE).
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "venvcleaner"
3
+ dynamic = []
4
+ authors = [
5
+ { name = "yamakox", email="notolog.tech@gmail.com" }
6
+ ]
7
+ description = "A GUI tool for cleaning up Python virtual environments (venv directories)."
8
+ readme = "README.md"
9
+ license = "MIT"
10
+ license-files = [ "LICENSE" ]
11
+ requires-python = ">=3.11"
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Operating System :: OS Independent",
16
+ "Development Status :: 4 - Beta",
17
+ ]
18
+ dependencies = [
19
+ "click>=8.3.0",
20
+ "wxpython>=4.2.3",
21
+ ]
22
+ version = "0.1.0"
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/yamakox/VenvCleaner"
26
+ Repository = "https://github.com/yamakox/VenvCleaner.git"
27
+ Issues = "https://github.com/yamakox/VenvCleaner/issues"
28
+
29
+ [project.scripts]
30
+ venvcleaner = "venvcleaner:main"
31
+
32
+ [build-system]
33
+ requires = ["poetry-core>=2,<3", "poetry-dynamic-versioning>=1.0.0,<2.0.0"]
34
+ build-backend = "poetry_dynamic_versioning.backend"
35
+
36
+ [tool.poetry]
37
+
38
+ [tool.poetry-dynamic-versioning]
39
+ enable = false
@@ -0,0 +1,3 @@
1
+ from .main import main
2
+
3
+ __all__ = ['main']
@@ -0,0 +1,4 @@
1
+ from .main import main
2
+
3
+ if __name__ == '__main__':
4
+ main()
@@ -0,0 +1,433 @@
1
+ from typing import Any
2
+ import click
3
+ from pathlib import Path
4
+ import shutil
5
+ import sys
6
+ import logging
7
+ import os
8
+ from datetime import datetime
9
+ import wx
10
+ import threading
11
+ from importlib.metadata import version, metadata
12
+
13
+ package_name = metadata(__package__).get('Name') # type: ignore
14
+ version_number = version(package_name)
15
+
16
+ # MARK: logger "venvcleaner"
17
+
18
+ logger: logging.Logger|None = logging.getLogger('venvcleaner')
19
+ debug = os.environ.get('DEBUG')
20
+ logger.setLevel(logging.DEBUG if debug else logging.INFO)
21
+ logging.basicConfig(
22
+ format="%(asctime)s [%(levelname)s] %(message)s",
23
+ )
24
+
25
+ # MARK: Constants
26
+
27
+ GLOB_PATTERN = 'pyvenv.cfg'
28
+
29
+ # MARK subroutines
30
+
31
+ def _compute_dir_size(dir_path):
32
+ dir_size = 0
33
+ for path in dir_path.rglob('*'):
34
+ if path.is_file():
35
+ dir_size += path.stat().st_size
36
+ return dir_size
37
+
38
+ def _format_size(size):
39
+ if size < 1024:
40
+ return f'{size} B'
41
+ elif size < 1024 * 1024:
42
+ return f'{size / 1024:.2f} KB'
43
+ elif size < 1024 * 1024 * 1024:
44
+ return f'{size / 1024 / 1024:.2f} MB'
45
+ else:
46
+ return f'{size / 1024 / 1024 / 1024:.2f} GB'
47
+
48
+ def _timestamp_to_local_str(timestamp):
49
+ return str(datetime.fromtimestamp(int(timestamp)))
50
+
51
+ def _quote_path(path):
52
+ _path = str(path)
53
+ return f'"{_path}"' if ' ' in _path else _path
54
+
55
+ # MARK: Events
56
+
57
+ myEVT_VENV_FOUND = wx.NewEventType()
58
+ EVT_VENV_FOUND = wx.PyEventBinder(myEVT_VENV_FOUND)
59
+
60
+ class VenvFoundEvent(wx.ThreadEvent):
61
+ def __init__(self, venv_path):
62
+ super().__init__(myEVT_VENV_FOUND)
63
+ self.venv_path = venv_path
64
+
65
+ myEVT_VENV_SIZE_COMPUTED = wx.NewEventType()
66
+ EVT_VENV_SIZE_COMPUTED = wx.PyEventBinder(myEVT_VENV_SIZE_COMPUTED)
67
+
68
+ class VenvSizeComputedEvent(wx.ThreadEvent):
69
+ def __init__(self, venv_path, venv_size):
70
+ super().__init__(myEVT_VENV_SIZE_COMPUTED)
71
+ self.venv_path = venv_path
72
+ self.venv_size = venv_size
73
+
74
+ myEVT_FIND_VENVS_COMPLETED = wx.NewEventType()
75
+ EVT_FIND_VENVS_COMPLETED = wx.PyEventBinder(myEVT_FIND_VENVS_COMPLETED)
76
+
77
+ class FindVenvsCompletedEvent(wx.ThreadEvent):
78
+ def __init__(self):
79
+ super().__init__(myEVT_FIND_VENVS_COMPLETED)
80
+
81
+ # MARK: Main Frame
82
+
83
+ class VenvCleanerFrame(wx.Frame):
84
+ def __init__(self, dir_path):
85
+ super().__init__(None, title=f'venv cleaner v{version_number}', size=(800, 600))
86
+ self.dir_path = Path(dir_path)
87
+
88
+ self.venvs_cache = {}
89
+ self.venvs_cache_inv = {}
90
+ self.total_size = 0
91
+ self.find_venvs_thread = None
92
+ self.Bind(EVT_VENV_FOUND, self.__on_venv_found)
93
+ self.Bind(EVT_VENV_SIZE_COMPUTED, self.__on_venv_size_computed)
94
+ self.Bind(EVT_FIND_VENVS_COMPLETED, self.__on_find_venvs_completed)
95
+ self.Bind(wx.EVT_CLOSE, self.__on_close)
96
+
97
+ self.sort_column = 1
98
+ self.sort_ascending = True
99
+
100
+ if sys.platform == 'darwin':
101
+ menu_bar = wx.MenuBar()
102
+ file_menu = wx.Menu()
103
+ file_menu_close = file_menu.Append(wx.ID_CLOSE, 'Close Window\tCtrl+W')
104
+ menu_bar.Append(file_menu, 'File')
105
+ self.SetMenuBar(menu_bar)
106
+ self.Bind(wx.EVT_MENU, self.__on_close_menu, file_menu_close)
107
+
108
+ self.__setup_main_panel()
109
+ self.__start_find_venvs_thread()
110
+
111
+ def __setup_main_panel(self):
112
+ frame_sizer = wx.GridSizer(rows=1, cols=1, gap=wx.Size(0, 0))
113
+ panel = wx.Panel(self)
114
+ sizer = wx.FlexGridSizer(rows=3, cols=1, gap=wx.Size(8, 8))
115
+ sizer.AddGrowableCol(0)
116
+ sizer.AddGrowableRow(1)
117
+
118
+ dir_path_panel = self.__setup_dir_path_panel(panel)
119
+ sizer.Add(dir_path_panel, flag=wx.EXPAND)
120
+
121
+ self.venv_list = wx.ListCtrl(panel, style=wx.LC_REPORT|wx.LC_HRULES|wx.LC_VRULES|wx.LC_SORT_ASCENDING)
122
+ self.venv_list.InsertColumn(0, 'Venv Name', width=100)
123
+ self.venv_list.InsertColumn(1, 'Location', width=350)
124
+ self.venv_list.InsertColumn(2, 'Size', wx.LIST_FORMAT_RIGHT, width=120)
125
+ self.venv_list.InsertColumn(3, 'Last Modified', width=180)
126
+ def on_venv_list_col_click(event):
127
+ column = event.GetColumn()
128
+ if column == self.sort_column:
129
+ self.sort_ascending = not self.sort_ascending
130
+ else:
131
+ self.sort_column = column
132
+ self.sort_ascending = True
133
+ self.venv_list.SortItems(self.__sort_venvs)
134
+ self.venv_list.ShowSortIndicator(self.sort_column, self.sort_ascending)
135
+ self.venv_list.Bind(wx.EVT_LIST_COL_CLICK, on_venv_list_col_click)
136
+ sizer.Add(self.venv_list, flag=wx.EXPAND|wx.ALL, border=2)
137
+
138
+ control_panel = self.__setup_control_panel(panel)
139
+ sizer.Add(control_panel, flag=wx.EXPAND)
140
+
141
+ panel.SetSizer(sizer)
142
+ self.SetSizer(frame_sizer)
143
+ frame_sizer.Add(panel, flag=wx.EXPAND|wx.ALL, border=8)
144
+
145
+ def __setup_dir_path_panel(self, parent_panel):
146
+ panel = wx.Panel(parent_panel)
147
+ sizer = wx.FlexGridSizer(rows=1, cols=3, gap=wx.Size(4, 0))
148
+ sizer.AddGrowableCol(0)
149
+
150
+ self.dir_path_input = wx.TextCtrl(panel, style=wx.TE_READONLY)
151
+ self.dir_path_input.SetValue(str(self.dir_path))
152
+ sizer.Add(self.dir_path_input, flag=wx.EXPAND|wx.ALL, border=2)
153
+
154
+ dir_path_button = wx.Button(panel, label='Select...')
155
+ def on_dir_path_button_click(event):
156
+ dialog = wx.DirDialog(self, 'Please select a directory.', style=wx.DD_DEFAULT_STYLE|wx.DD_DIR_MUST_EXIST)
157
+ if dialog.ShowModal() == wx.ID_OK:
158
+ self.dir_path = Path(dialog.GetPath())
159
+ self.dir_path_input.SetValue(str(self.dir_path))
160
+ self.__start_find_venvs_thread()
161
+ dir_path_button.Bind(wx.EVT_BUTTON, on_dir_path_button_click)
162
+ sizer.Add(dir_path_button, flag=wx.EXPAND|wx.ALL, border=2)
163
+
164
+ refresh_button = wx.Button(panel, label='Refresh')
165
+ def on_refresh_button_click(event):
166
+ self.__start_find_venvs_thread()
167
+ refresh_button.Bind(wx.EVT_BUTTON, on_refresh_button_click)
168
+ sizer.Add(refresh_button, flag=wx.EXPAND|wx.ALL, border=2)
169
+
170
+ panel.SetSizer(sizer)
171
+ return panel
172
+
173
+ def __setup_control_panel(self, parent_panel):
174
+ self.control_panel = wx.Panel(parent_panel)
175
+ control_sizer = wx.FlexGridSizer(rows=2, cols=1, gap=wx.Size(8, 8))
176
+ control_sizer.AddGrowableCol(0)
177
+
178
+ first_panel = wx.Panel(self.control_panel)
179
+ first_sizer = wx.FlexGridSizer(rows=1, cols=4, gap=wx.Size(4, 4))
180
+ first_sizer.AddGrowableCol(2)
181
+
182
+ select_all_button = wx.Button(first_panel, label='Select All')
183
+ def on_select_all_button_click(event):
184
+ for row in range(self.venv_list.GetItemCount()):
185
+ self.venv_list.Select(row, on=True)
186
+ select_all_button.Bind(wx.EVT_BUTTON, on_select_all_button_click)
187
+ first_sizer.Add(select_all_button, flag=wx.EXPAND|wx.ALL, border=2)
188
+
189
+ select_none_button = wx.Button(first_panel, label='Select None')
190
+ def on_select_none_button_click(event):
191
+ for row in range(self.venv_list.GetItemCount()):
192
+ self.venv_list.Select(row, on=False)
193
+ select_none_button.Bind(wx.EVT_BUTTON, on_select_none_button_click)
194
+ first_sizer.Add(select_none_button, flag=wx.EXPAND|wx.ALL, border=2)
195
+
196
+ self.status_text = wx.StaticText(first_panel, label='', style=wx.ALIGN_CENTER_HORIZONTAL)
197
+ first_sizer.Add(self.status_text, flag=wx.EXPAND|wx.ALL, border=2)
198
+
199
+ copy_button = wx.Button(first_panel, label='Copy Paths')
200
+ def on_copy_button_click(event):
201
+ self.__copy_paths()
202
+ copy_button.Bind(wx.EVT_BUTTON, on_copy_button_click)
203
+ first_sizer.Add(copy_button, flag=wx.EXPAND|wx.ALL, border=2)
204
+
205
+ first_panel.SetSizer(first_sizer)
206
+ control_sizer.Add(first_panel, flag=wx.EXPAND)
207
+
208
+ second_panel = wx.Panel(self.control_panel)
209
+ second_sizer = wx.FlexGridSizer(rows=1, cols=3, gap=wx.Size(4, 4))
210
+ second_sizer.AddGrowableCol(0)
211
+ second_sizer.AddStretchSpacer(1)
212
+
213
+ agree_checkbox = wx.CheckBox(second_panel, label='I agree to take responsibility for my actions.')
214
+ second_sizer.Add(agree_checkbox, flag=wx.EXPAND|wx.ALL, border=2)
215
+
216
+ clean_button = wx.Button(second_panel, label='Cleanup Venvs')
217
+ clean_button.Enable(agree_checkbox.IsChecked())
218
+ def on_clean_button_click(event):
219
+ self.__clean_venvs()
220
+ clean_button.Bind(wx.EVT_BUTTON, on_clean_button_click)
221
+ second_sizer.Add(clean_button, flag=wx.EXPAND|wx.ALL, border=2)
222
+
223
+ second_panel.SetSizer(second_sizer)
224
+ control_sizer.Add(second_panel, flag=wx.EXPAND)
225
+
226
+ def on_agree_checkbox_click(event):
227
+ clean_button.Enable(event.IsChecked())
228
+ agree_checkbox.Bind(wx.EVT_CHECKBOX, on_agree_checkbox_click)
229
+
230
+ self.control_panel.SetSizer(control_sizer)
231
+ return self.control_panel
232
+
233
+ def __set_status_text(self, text):
234
+ self.status_text.SetLabel(text)
235
+ self.control_panel.Layout()
236
+
237
+ def __start_find_venvs_thread(self):
238
+ self.__ensure_stop_thread()
239
+ self.venvs_cache.clear()
240
+ self.venvs_cache_inv.clear()
241
+ self.total_size = 0
242
+ self.venv_list.DeleteAllItems()
243
+ self.__set_status_text('Finding venvs...')
244
+ self.find_venvs_thread = threading.Thread(
245
+ target=self.__find_venvs_worker,
246
+ args=(self.dir_path,),
247
+ daemon=True,
248
+ )
249
+ self.find_venvs_thread.start()
250
+
251
+ def __ensure_stop_thread(self):
252
+ th = self.find_venvs_thread
253
+ if th is not None:
254
+ self.find_venvs_thread = None
255
+ th.join()
256
+
257
+ def __find_venvs_worker(self, dir_path):
258
+ venv_paths = []
259
+ try:
260
+ for path in dir_path.rglob(GLOB_PATTERN):
261
+ if self.find_venvs_thread is None:
262
+ return
263
+ if path.is_file():
264
+ venv_path = path.parent
265
+ venv_paths.append(venv_path)
266
+ wx.QueueEvent(self, VenvFoundEvent(venv_path))
267
+ wx.QueueEvent(self, FindVenvsCompletedEvent())
268
+ for venv_path in venv_paths:
269
+ if self.find_venvs_thread is None:
270
+ return
271
+ venv_size = _compute_dir_size(venv_path)
272
+ wx.QueueEvent(self, VenvSizeComputedEvent(venv_path, venv_size))
273
+ except Exception as e:
274
+ logger.error(f'Failed to find venvs: {e}')
275
+ wx.QueueEvent(self, FindVenvsCompletedEvent())
276
+ finally:
277
+ self.find_venvs_thread = None
278
+
279
+ def __on_venv_found(self, event):
280
+ mtime = event.venv_path.stat().st_mtime
281
+ self.venv_list.Append([
282
+ event.venv_path.name,
283
+ str(event.venv_path.relative_to(self.dir_path).parent),
284
+ '...',
285
+ _timestamp_to_local_str(mtime),
286
+ ])
287
+ id = self.venv_list.GetItemCount()
288
+ venv_info = {'path': event.venv_path, 'size': 0, 'id': id, 't': mtime}
289
+ self.venvs_cache[id] = venv_info
290
+ self.venvs_cache_inv[event.venv_path] = venv_info
291
+ self.venv_list.SetItemData(id - 1, id)
292
+
293
+ def __on_venv_size_computed(self, event):
294
+ if event.venv_path not in self.venvs_cache_inv:
295
+ return
296
+ venv_info = self.venvs_cache_inv[event.venv_path]
297
+ index = self.venv_list.FindItem(-1, venv_info['id'])
298
+ if index >= 0:
299
+ venv_info['size'] = event.venv_size
300
+ self.total_size += event.venv_size
301
+ self.venv_list.SetItem(index, 2, _format_size(event.venv_size))
302
+ if self.sort_column == 2:
303
+ self.venv_list.SortItems(self.__sort_venvs)
304
+ self.venv_list.ShowSortIndicator(self.sort_column, self.sort_ascending)
305
+ n = self.venv_list.GetItemCount()
306
+ self.__set_status_text(f'Found {n} venvs. Total size: {_format_size(self.total_size)}')
307
+
308
+ def __on_find_venvs_completed(self, event):
309
+ n = self.venv_list.GetItemCount()
310
+ self.__set_status_text(f'Found {n} venvs. Total size: {_format_size(self.total_size)}')
311
+ self.venv_list.ShowSortIndicator(self.sort_column, self.sort_ascending)
312
+ self.venv_list.SortItems(self.__sort_venvs)
313
+
314
+ def __sort_venvs(self, item1, item2):
315
+ venv_info1 = self.venvs_cache[item1]
316
+ venv_info2 = self.venvs_cache[item2]
317
+ if self.sort_column == 0:
318
+ val1 = venv_info1['path'].name.lower()
319
+ val2 = venv_info2['path'].name.lower()
320
+ elif self.sort_column == 1:
321
+ val1 = str(venv_info1['path'].parent).lower()
322
+ val2 = str(venv_info2['path'].parent).lower()
323
+ elif self.sort_column == 2:
324
+ val1 = venv_info1['size']
325
+ val2 = venv_info2['size']
326
+ elif self.sort_column == 3:
327
+ val1 = venv_info1['t']
328
+ val2 = venv_info2['t']
329
+ else:
330
+ return 0
331
+ if self.sort_ascending:
332
+ return (val1 > val2) - (val1 < val2)
333
+ else:
334
+ return (val1 < val2) - (val1 > val2)
335
+
336
+ def __copy_paths(self):
337
+ selected_count = self.venv_list.GetSelectedItemCount()
338
+ if selected_count == 0:
339
+ wx.MessageBox('Please select at least one venv to copy the paths.', 'Warning', wx.OK|wx.ICON_WARNING)
340
+ return
341
+ paths = []
342
+ for row in range(self.venv_list.GetItemCount()):
343
+ if self.venv_list.IsSelected(row):
344
+ id = self.venv_list.GetItemData(row)
345
+ venv_info = self.venvs_cache[id]
346
+ paths.append(_quote_path(venv_info['path']))
347
+ if wx.TheClipboard.Open():
348
+ wx.TheClipboard.SetData(wx.TextDataObject(' '.join(paths)))
349
+ wx.TheClipboard.Close()
350
+ wx.MessageBox(f'{len(paths)} venv path(s) have been copied to the clipboard.', 'Success', wx.OK|wx.ICON_INFORMATION)
351
+ else:
352
+ wx.MessageBox('Failed to open the clipboard. Please try again.', 'Error', wx.OK|wx.ICON_ERROR)
353
+
354
+ def __clean_venvs(self):
355
+ selected_count = self.venv_list.GetSelectedItemCount()
356
+ if selected_count == 0:
357
+ wx.MessageBox('Please select at least one venv to clean up.', 'Warning', wx.OK|wx.ICON_WARNING)
358
+ return
359
+ answer = wx.MessageBox(
360
+ f'Are you sure you want to clean up {selected_count} venv(s)?',
361
+ 'Confirm',
362
+ wx.OK|wx.CANCEL|wx.CANCEL_DEFAULT|wx.ICON_QUESTION
363
+ )
364
+ if answer != wx.OK:
365
+ return
366
+ self.__ensure_stop_thread()
367
+ cleaned_count = 0
368
+ error_count = 0
369
+ row = 0
370
+ while row < self.venv_list.GetItemCount():
371
+ try:
372
+ id = self.venv_list.GetItemData(row)
373
+ venv_info = self.venvs_cache[id]
374
+ venv_path = venv_info['path']
375
+ if self.venv_list.IsSelected(row):
376
+ logger.info(f'Cleaned up: {venv_path}')
377
+ shutil.rmtree(venv_path)
378
+ self.venv_list.DeleteItem(row)
379
+ cleaned_count += 1
380
+ self.total_size -= venv_info['size']
381
+ continue
382
+ except Exception:
383
+ error_count += 1
384
+ logger.error(f'Failed to clean up: {venv_path}')
385
+ row += 1
386
+ n = self.venv_list.GetItemCount()
387
+ self.__set_status_text(f'{n} venv(s) remaining. Total size: {_format_size(self.total_size)}')
388
+ self.venv_list.Update()
389
+ if error_count > 0:
390
+ wx.MessageBox(
391
+ (
392
+ f'Failed to clean up {error_count} of {cleaned_count + error_count} venv(s). '
393
+ 'Please check the permissions and try again.'
394
+ ),
395
+ 'Error',
396
+ wx.OK|wx.ICON_ERROR
397
+ )
398
+ else:
399
+ wx.MessageBox(
400
+ f'Cleaned up {cleaned_count} venv(s).',
401
+ 'Success',
402
+ wx.OK|wx.ICON_INFORMATION
403
+ )
404
+
405
+ def __on_close_menu(self, event):
406
+ self.Close()
407
+ event.Skip()
408
+
409
+ def __on_close(self, event):
410
+ self.__ensure_stop_thread()
411
+ event.Skip()
412
+
413
+ # MARK: Main App
414
+
415
+ class VenvCleanerApp(wx.App):
416
+ def __init__(self, dir_path):
417
+ super().__init__()
418
+
419
+ lang_code = wx.Locale().GetSystemLanguage()
420
+ locale = wx.Locale(lang_code)
421
+
422
+ self.dir_path = Path(dir_path)
423
+ self.frame = VenvCleanerFrame(self.dir_path)
424
+ self.frame.Show()
425
+ self.frame.venv_list.SetFocus()
426
+
427
+ # MARK: Main Function
428
+
429
+ @click.command()
430
+ @click.argument('dir_path', nargs=1, default='.', type=click.Path(exists=True, file_okay=False, resolve_path=True))
431
+ def main(dir_path):
432
+ app = VenvCleanerApp(dir_path)
433
+ app.MainLoop()