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
|
+

|
|
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
|
+

|
|
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,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()
|