ptdu 0.1.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.
- ptdu/__init__.py +38 -0
- ptdu/cache.py +374 -0
- ptdu/errors.py +627 -0
- ptdu/fonts.py +237 -0
- ptdu/main.py +118 -0
- ptdu/models.py +130 -0
- ptdu/performance.py +348 -0
- ptdu/scanner.py +490 -0
- ptdu/threads.py +250 -0
- ptdu/treeview.py +426 -0
- ptdu/ui.py +1247 -0
- ptdu/utils.py +80 -0
- ptdu-0.1.0.dist-info/METADATA +341 -0
- ptdu-0.1.0.dist-info/RECORD +17 -0
- ptdu-0.1.0.dist-info/WHEEL +4 -0
- ptdu-0.1.0.dist-info/entry_points.txt +2 -0
- ptdu-0.1.0.dist-info/licenses/LICENSE +21 -0
ptdu/performance.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Performance optimizations for large directory handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING, Callable, Optional
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from ptdu.models import DirNode
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class VirtualRange:
|
|
15
|
+
"""Represents a visible range for virtual scrolling."""
|
|
16
|
+
|
|
17
|
+
start: int
|
|
18
|
+
end: int
|
|
19
|
+
total: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class VirtualScroller:
|
|
23
|
+
"""Manages virtual scrolling for large directories."""
|
|
24
|
+
|
|
25
|
+
# Default number of items to render
|
|
26
|
+
DEFAULT_WINDOW_SIZE: int = 100
|
|
27
|
+
# Buffer size for smooth scrolling
|
|
28
|
+
BUFFER_SIZE: int = 20
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self, total_items: int = 0, window_size: int = DEFAULT_WINDOW_SIZE
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Initialize virtual scroller.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
total_items: Total number of items
|
|
37
|
+
window_size: Number of items to render at once
|
|
38
|
+
"""
|
|
39
|
+
self._total: int = total_items
|
|
40
|
+
self._window_size: int = window_size
|
|
41
|
+
self._current_start: int = 0
|
|
42
|
+
|
|
43
|
+
def set_total(self, total: int) -> None:
|
|
44
|
+
"""Update total item count.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
total: New total number of items
|
|
48
|
+
"""
|
|
49
|
+
self._total = total
|
|
50
|
+
self._clamp_start()
|
|
51
|
+
|
|
52
|
+
def get_visible_range(self) -> VirtualRange:
|
|
53
|
+
"""Get the currently visible range.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
VirtualRange with start, end, and total
|
|
57
|
+
"""
|
|
58
|
+
end = min(self._current_start + self._window_size, self._total)
|
|
59
|
+
return VirtualRange(
|
|
60
|
+
start=self._current_start,
|
|
61
|
+
end=end,
|
|
62
|
+
total=self._total,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def scroll_to(self, position: int) -> VirtualRange:
|
|
66
|
+
"""Scroll to a specific position.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
position: Target position (0-based)
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
New visible range
|
|
73
|
+
"""
|
|
74
|
+
self._current_start = max(0, min(position, self._total - 1))
|
|
75
|
+
self._clamp_start()
|
|
76
|
+
return self.get_visible_range()
|
|
77
|
+
|
|
78
|
+
def scroll_down(self, amount: int = 1) -> VirtualRange:
|
|
79
|
+
"""Scroll down by amount.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
amount: Number of items to scroll
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
New visible range
|
|
86
|
+
"""
|
|
87
|
+
self._current_start += amount
|
|
88
|
+
self._clamp_start()
|
|
89
|
+
return self.get_visible_range()
|
|
90
|
+
|
|
91
|
+
def scroll_up(self, amount: int = 1) -> VirtualRange:
|
|
92
|
+
"""Scroll up by amount.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
amount: Number of items to scroll
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
New visible range
|
|
99
|
+
"""
|
|
100
|
+
self._current_start -= amount
|
|
101
|
+
self._clamp_start()
|
|
102
|
+
return self.get_visible_range()
|
|
103
|
+
|
|
104
|
+
def page_down(self) -> VirtualRange:
|
|
105
|
+
"""Scroll down by one page.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
New visible range
|
|
109
|
+
"""
|
|
110
|
+
return self.scroll_down(self._window_size)
|
|
111
|
+
|
|
112
|
+
def page_up(self) -> VirtualRange:
|
|
113
|
+
"""Scroll up by one page.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
New visible range
|
|
117
|
+
"""
|
|
118
|
+
return self.scroll_up(self._window_size)
|
|
119
|
+
|
|
120
|
+
def _clamp_start(self) -> None:
|
|
121
|
+
"""Clamp start position to valid range."""
|
|
122
|
+
max_start = max(0, self._total - self._window_size)
|
|
123
|
+
self._current_start = max(0, min(self._current_start, max_start))
|
|
124
|
+
|
|
125
|
+
def is_visible(self, index: int) -> bool:
|
|
126
|
+
"""Check if an index is currently visible.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
index: Item index to check
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
True if visible
|
|
133
|
+
"""
|
|
134
|
+
visible = self.get_visible_range()
|
|
135
|
+
return visible.start <= index < visible.end
|
|
136
|
+
|
|
137
|
+
def get_item_index_at(self, y_position: int, item_height: int) -> int:
|
|
138
|
+
"""Get item index at a Y position.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
y_position: Y coordinate
|
|
142
|
+
item_height: Height of each item in pixels
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Item index
|
|
146
|
+
"""
|
|
147
|
+
return self._current_start + (y_position // item_height)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class MemoryOptimizer:
|
|
151
|
+
"""Optimizes memory usage for large directory trees."""
|
|
152
|
+
|
|
153
|
+
# Maximum children to keep in memory per directory
|
|
154
|
+
MAX_CACHED_CHILDREN: int = 1000
|
|
155
|
+
# Maximum depth for initial scan
|
|
156
|
+
MAX_INITIAL_DEPTH: int = 3
|
|
157
|
+
|
|
158
|
+
def __init__(self) -> None:
|
|
159
|
+
"""Initialize memory optimizer."""
|
|
160
|
+
self._unloaded_nodes: set[str] = set()
|
|
161
|
+
self._max_depth_reached: int = 0
|
|
162
|
+
|
|
163
|
+
def should_unload_children(self, node: DirNode) -> bool:
|
|
164
|
+
"""Check if a node's children should be unloaded.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
node: Directory node to check
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
True if children should be unloaded
|
|
171
|
+
"""
|
|
172
|
+
if not node.is_dir:
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
# Unload if too many children
|
|
176
|
+
if len(node.children) > self.MAX_CACHED_CHILDREN:
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
def unload_children(self, node: DirNode) -> list[str]:
|
|
182
|
+
"""Unload children from memory, returning names of unloaded children.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
node: Directory node to unload
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
List of unloaded child names
|
|
189
|
+
"""
|
|
190
|
+
if not node.is_dir:
|
|
191
|
+
return []
|
|
192
|
+
|
|
193
|
+
unloaded: list[str] = []
|
|
194
|
+
|
|
195
|
+
# Keep track of unloaded nodes
|
|
196
|
+
for name, child in node.children.items():
|
|
197
|
+
self._unloaded_nodes.add(str(child.path))
|
|
198
|
+
unloaded.append(name)
|
|
199
|
+
|
|
200
|
+
# Clear children but keep size info
|
|
201
|
+
node.children.clear()
|
|
202
|
+
node._is_loaded = False
|
|
203
|
+
|
|
204
|
+
return unloaded
|
|
205
|
+
|
|
206
|
+
def reload_children(
|
|
207
|
+
self,
|
|
208
|
+
node: DirNode,
|
|
209
|
+
loader: Callable[[], list[DirNode]],
|
|
210
|
+
) -> None:
|
|
211
|
+
"""Reload children using a loader function.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
node: Directory node to reload
|
|
215
|
+
loader: Function that returns list of child nodes
|
|
216
|
+
"""
|
|
217
|
+
if not node.is_dir:
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
children = loader()
|
|
221
|
+
for child in children:
|
|
222
|
+
node.add_child(child)
|
|
223
|
+
|
|
224
|
+
node._is_loaded = True
|
|
225
|
+
self._unloaded_nodes.discard(str(node.path))
|
|
226
|
+
|
|
227
|
+
def get_initial_scan_depth(self, estimated_total_items: int) -> int:
|
|
228
|
+
"""Get recommended initial scan depth based on estimated size.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
estimated_total_items: Estimated number of items
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
Recommended scan depth
|
|
235
|
+
"""
|
|
236
|
+
if estimated_total_items > 100000:
|
|
237
|
+
return 2
|
|
238
|
+
elif estimated_total_items > 10000:
|
|
239
|
+
return 3
|
|
240
|
+
else:
|
|
241
|
+
return self.MAX_INITIAL_DEPTH
|
|
242
|
+
|
|
243
|
+
def limit_recursion_depth(
|
|
244
|
+
self, current_depth: int, max_depth: Optional[int]
|
|
245
|
+
) -> int:
|
|
246
|
+
"""Calculate effective max depth with limits.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
current_depth: Current recursion depth
|
|
250
|
+
max_depth: Requested max depth (None for unlimited)
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
Effective max depth
|
|
254
|
+
"""
|
|
255
|
+
if max_depth is None:
|
|
256
|
+
# Apply safety limit for unlimited scans
|
|
257
|
+
return current_depth + 10
|
|
258
|
+
|
|
259
|
+
return max_depth
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class LargeDirectoryHandler:
|
|
263
|
+
"""Handles special cases for very large directories."""
|
|
264
|
+
|
|
265
|
+
# Threshold for considering a directory "large"
|
|
266
|
+
LARGE_DIR_THRESHOLD: int = 10000
|
|
267
|
+
|
|
268
|
+
def __init__(self) -> None:
|
|
269
|
+
"""Initialize large directory handler."""
|
|
270
|
+
self._large_directories: set[str] = set()
|
|
271
|
+
self._item_counts: dict[str, int] = {}
|
|
272
|
+
|
|
273
|
+
def register_directory(self, path: str, item_count: int) -> None:
|
|
274
|
+
"""Register a directory with its item count.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
path: Directory path
|
|
278
|
+
item_count: Number of items
|
|
279
|
+
"""
|
|
280
|
+
self._item_counts[path] = item_count
|
|
281
|
+
|
|
282
|
+
if item_count > self.LARGE_DIR_THRESHOLD:
|
|
283
|
+
self._large_directories.add(path)
|
|
284
|
+
|
|
285
|
+
def is_large_directory(self, path: str) -> bool:
|
|
286
|
+
"""Check if a directory is considered large.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
path: Directory path
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
True if large
|
|
293
|
+
"""
|
|
294
|
+
return path in self._large_directories
|
|
295
|
+
|
|
296
|
+
def get_item_count(self, path: str) -> int:
|
|
297
|
+
"""Get cached item count for a directory.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
path: Directory path
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
Item count or 0
|
|
304
|
+
"""
|
|
305
|
+
return self._item_counts.get(path, 0)
|
|
306
|
+
|
|
307
|
+
def get_memory_usage_estimate(self) -> dict[str, int]:
|
|
308
|
+
"""Estimate current memory usage.
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
Dictionary with memory stats in bytes
|
|
312
|
+
"""
|
|
313
|
+
# Rough estimates
|
|
314
|
+
node_overhead = sys.getsizeof(object()) + 200 # Base node size
|
|
315
|
+
total_nodes = sum(self._item_counts.values())
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
"estimated_bytes": total_nodes * node_overhead,
|
|
319
|
+
"total_nodes": total_nodes,
|
|
320
|
+
"large_directories": len(self._large_directories),
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def estimate_directory_size(path: str) -> int:
|
|
325
|
+
"""Quickly estimate number of items in a directory.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
path: Directory path
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
Estimated item count
|
|
332
|
+
"""
|
|
333
|
+
import os
|
|
334
|
+
from pathlib import Path
|
|
335
|
+
|
|
336
|
+
count = 0
|
|
337
|
+
try:
|
|
338
|
+
# Quick scan - only first level
|
|
339
|
+
with os.scandir(path) as entries:
|
|
340
|
+
for entry in entries:
|
|
341
|
+
count += 1
|
|
342
|
+
# Limit quick scan
|
|
343
|
+
if count >= 1000:
|
|
344
|
+
return 1000
|
|
345
|
+
except (OSError, PermissionError):
|
|
346
|
+
pass
|
|
347
|
+
|
|
348
|
+
return count
|