nano-dev-utils 1.4.0__py3-none-any.whl → 1.5.2__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.
Potentially problematic release.
This version of nano-dev-utils might be problematic. Click here for more details.
- nano_dev_utils/common.py +51 -17
- nano_dev_utils/file_tree_display.py +187 -86
- nano_dev_utils/timers.py +83 -49
- nano_dev_utils-1.5.2.dist-info/METADATA +372 -0
- nano_dev_utils-1.5.2.dist-info/RECORD +10 -0
- nano_dev_utils-1.4.0.dist-info/METADATA +0 -270
- nano_dev_utils-1.4.0.dist-info/RECORD +0 -10
- {nano_dev_utils-1.4.0.dist-info → nano_dev_utils-1.5.2.dist-info}/WHEEL +0 -0
- {nano_dev_utils-1.4.0.dist-info → nano_dev_utils-1.5.2.dist-info}/licenses/LICENSE +0 -0
nano_dev_utils/timers.py
CHANGED
|
@@ -21,11 +21,20 @@ lgr = logging.getLogger(__name__)
|
|
|
21
21
|
P = ParamSpec('P')
|
|
22
22
|
R = TypeVar('R')
|
|
23
23
|
|
|
24
|
+
NS_IN_US = 1_000
|
|
25
|
+
NS_IN_MS = 1_000_000
|
|
26
|
+
NS_IN_SEC = 1_000_000_000
|
|
27
|
+
NS_IN_MIN = 60 * NS_IN_SEC
|
|
28
|
+
NS_IN_HOUR = 60 * NS_IN_MIN
|
|
29
|
+
|
|
24
30
|
|
|
25
31
|
class Timer:
|
|
26
|
-
def __init__(
|
|
32
|
+
def __init__(
|
|
33
|
+
self, precision: int = 4, verbose: bool = False, printout: bool = False
|
|
34
|
+
):
|
|
27
35
|
self.precision = precision
|
|
28
36
|
self.verbose = verbose
|
|
37
|
+
self.printout = printout
|
|
29
38
|
|
|
30
39
|
def init(self, *args, **kwargs) -> None:
|
|
31
40
|
self.__init__(*args, **kwargs)
|
|
@@ -33,6 +42,9 @@ class Timer:
|
|
|
33
42
|
def update(self, attrs: dict[str, Any]) -> None:
|
|
34
43
|
update(self, attrs)
|
|
35
44
|
|
|
45
|
+
def res_formatter(self, elapsed_ns: float, *, precision: int = 4) -> str:
|
|
46
|
+
return self._duration_formatter(elapsed_ns, precision=precision)
|
|
47
|
+
|
|
36
48
|
def timeit(
|
|
37
49
|
self,
|
|
38
50
|
iterations: int = 1,
|
|
@@ -42,9 +54,9 @@ class Timer:
|
|
|
42
54
|
"""Decorator that measures execution time for sync / async functions.
|
|
43
55
|
|
|
44
56
|
Args:
|
|
45
|
-
iterations: Number of times to run the function (averaged for reporting).
|
|
46
|
-
timeout: Optional max allowed time (in seconds); raises TimeoutError if exceeded.
|
|
47
|
-
per_iteration: If True, enforces timeout per iteration, else cumulatively.
|
|
57
|
+
iterations: int: Number of times to run the function (averaged for reporting).
|
|
58
|
+
timeout: float: Optional max allowed time (in seconds); raises TimeoutError if exceeded.
|
|
59
|
+
per_iteration: bool: If True, enforces timeout per iteration, else cumulatively.
|
|
48
60
|
|
|
49
61
|
Returns:
|
|
50
62
|
A decorated function that behaves identically to the original, with timing logged.
|
|
@@ -53,12 +65,14 @@ class Timer:
|
|
|
53
65
|
RP = ParamSpec('RP')
|
|
54
66
|
RR = TypeVar('RR')
|
|
55
67
|
|
|
56
|
-
precision = self.precision
|
|
68
|
+
precision, verbose, printout = self.precision, self.verbose, self.printout
|
|
69
|
+
check_timeout = self._check_timeout
|
|
70
|
+
duration_formatter = self._duration_formatter
|
|
71
|
+
format_timing_msg = self._format_timing_msg
|
|
57
72
|
|
|
58
73
|
def decorator(
|
|
59
74
|
func: Callable[RP, RR] | Callable[RP, Awaitable[RR]],
|
|
60
75
|
) -> Callable[RP, Any]:
|
|
61
|
-
verbose = self.verbose
|
|
62
76
|
if inspect.iscoroutinefunction(func):
|
|
63
77
|
async_func = cast(Callable[RP, Awaitable[RR]], func)
|
|
64
78
|
|
|
@@ -73,21 +87,24 @@ class Timer:
|
|
|
73
87
|
duration_ns = time.perf_counter_ns() - start_ns
|
|
74
88
|
total_elapsed_ns += duration_ns
|
|
75
89
|
|
|
76
|
-
|
|
90
|
+
check_timeout(
|
|
77
91
|
func_name,
|
|
78
92
|
i,
|
|
79
93
|
duration_ns,
|
|
80
94
|
total_elapsed_ns,
|
|
81
95
|
timeout,
|
|
82
96
|
per_iteration,
|
|
97
|
+
precision,
|
|
83
98
|
)
|
|
84
99
|
avg_elapsed_ns = total_elapsed_ns / iterations
|
|
85
|
-
duration_str =
|
|
100
|
+
duration_str = duration_formatter(avg_elapsed_ns, precision)
|
|
86
101
|
|
|
87
|
-
msg =
|
|
102
|
+
msg = format_timing_msg(
|
|
88
103
|
func_name, args, kwargs, duration_str, iterations, verbose
|
|
89
104
|
)
|
|
90
105
|
lgr.info(msg)
|
|
106
|
+
if printout:
|
|
107
|
+
print(msg)
|
|
91
108
|
return cast(RR, result)
|
|
92
109
|
|
|
93
110
|
return cast(Callable[RP, Awaitable[RR]], async_wrapper)
|
|
@@ -104,92 +121,94 @@ class Timer:
|
|
|
104
121
|
result = sync_func(*args, **kwargs)
|
|
105
122
|
duration_ns = time.perf_counter_ns() - start_ns
|
|
106
123
|
total_elapsed_ns += duration_ns
|
|
107
|
-
|
|
124
|
+
check_timeout(
|
|
108
125
|
func_name,
|
|
109
126
|
i,
|
|
110
127
|
duration_ns,
|
|
111
128
|
total_elapsed_ns,
|
|
112
129
|
timeout,
|
|
113
130
|
per_iteration,
|
|
131
|
+
precision,
|
|
114
132
|
)
|
|
115
133
|
avg_elapsed_ns = total_elapsed_ns / iterations
|
|
116
|
-
duration_str =
|
|
117
|
-
msg =
|
|
134
|
+
duration_str = duration_formatter(avg_elapsed_ns, precision)
|
|
135
|
+
msg = format_timing_msg(
|
|
118
136
|
func_name, args, kwargs, duration_str, iterations, verbose
|
|
119
137
|
)
|
|
120
138
|
lgr.info(msg)
|
|
139
|
+
if printout:
|
|
140
|
+
print(msg)
|
|
121
141
|
return cast(RR, result)
|
|
122
142
|
|
|
123
143
|
return cast(Callable[RP, RR], sync_wrapper)
|
|
124
144
|
|
|
125
145
|
return decorator
|
|
126
146
|
|
|
147
|
+
@staticmethod
|
|
127
148
|
def _check_timeout(
|
|
128
|
-
self,
|
|
129
149
|
func_name: str,
|
|
130
150
|
i: int,
|
|
131
151
|
duration_ns: float,
|
|
132
152
|
total_elapsed_ns: float,
|
|
133
153
|
timeout: float | None,
|
|
134
154
|
per_iteration: bool,
|
|
155
|
+
precision,
|
|
135
156
|
) -> None:
|
|
136
157
|
"""Raise TimeoutError if timeout is exceeded."""
|
|
137
158
|
if timeout is None:
|
|
138
159
|
return
|
|
139
|
-
|
|
140
|
-
timeout_exceeded = f'{func_name} exceeded {timeout:.{precision}f}s'
|
|
160
|
+
|
|
161
|
+
timeout_exceeded = f'{func_name} exceeded {timeout:.{precision}f} s'
|
|
141
162
|
if per_iteration:
|
|
142
|
-
duration_s = duration_ns /
|
|
163
|
+
duration_s = duration_ns / NS_IN_SEC
|
|
143
164
|
if duration_s > timeout:
|
|
144
165
|
raise TimeoutError(
|
|
145
166
|
f'{timeout_exceeded} on iteration {i} '
|
|
146
|
-
f'(took {duration_s:.{precision}f}s)'
|
|
167
|
+
f'(took {duration_s:.{precision}f} s)'
|
|
147
168
|
)
|
|
148
169
|
else:
|
|
149
|
-
total_duration_s = total_elapsed_ns /
|
|
170
|
+
total_duration_s = total_elapsed_ns / NS_IN_SEC
|
|
150
171
|
if total_duration_s > timeout:
|
|
151
172
|
raise TimeoutError(
|
|
152
173
|
f'{timeout_exceeded} after {i} iterations '
|
|
153
|
-
f'(took {total_duration_s:.{precision}f}s)'
|
|
174
|
+
f'(took {total_duration_s:.{precision}f} s)'
|
|
154
175
|
)
|
|
155
176
|
|
|
156
177
|
@staticmethod
|
|
157
178
|
def _duration_formatter(elapsed_ns: float, precision: int = 4) -> str:
|
|
158
|
-
"""
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
return f'{
|
|
172
|
-
|
|
173
|
-
if elapsed_ns >=
|
|
174
|
-
hours =
|
|
175
|
-
rem =
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
parts = [f'{hours}h']
|
|
179
|
+
"""Format a duration [ns] into the most appropriate time unit.
|
|
180
|
+
|
|
181
|
+
Converts ns into a human-readable string with adaptive precision.
|
|
182
|
+
Handles ns, μs, ms, s, m, and h, combining units where meaningful.
|
|
183
|
+
"""
|
|
184
|
+
if elapsed_ns < NS_IN_SEC:
|
|
185
|
+
if elapsed_ns >= NS_IN_MS:
|
|
186
|
+
return f'{elapsed_ns / NS_IN_MS:.{precision}f} ms'
|
|
187
|
+
if elapsed_ns >= NS_IN_US:
|
|
188
|
+
return f'{elapsed_ns / NS_IN_US:.{precision}f} μs'
|
|
189
|
+
return f'{elapsed_ns:.{precision}f} ns'
|
|
190
|
+
|
|
191
|
+
if elapsed_ns < NS_IN_MIN:
|
|
192
|
+
return f'{elapsed_ns / NS_IN_SEC:.{precision}f} s'
|
|
193
|
+
|
|
194
|
+
if elapsed_ns >= NS_IN_HOUR:
|
|
195
|
+
hours, rem = divmod(elapsed_ns, NS_IN_HOUR)
|
|
196
|
+
mins, rem = divmod(rem, NS_IN_MIN)
|
|
197
|
+
secs = rem // NS_IN_SEC
|
|
198
|
+
|
|
199
|
+
parts = [f'{int(hours)} h']
|
|
180
200
|
if mins:
|
|
181
|
-
parts.append(f'{mins}m')
|
|
201
|
+
parts.append(f'{int(mins)} m')
|
|
182
202
|
if secs:
|
|
183
|
-
parts.append(f'{secs}s')
|
|
203
|
+
parts.append(f'{int(secs)} s')
|
|
184
204
|
return ' '.join(parts)
|
|
185
205
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
return f'{minutes}m {seconds}s' if seconds else f'{minutes}m'
|
|
206
|
+
mins, rem = divmod(elapsed_ns, NS_IN_MIN)
|
|
207
|
+
secs = rem // NS_IN_SEC
|
|
208
|
+
return f'{int(mins)} m {int(secs)} s' if secs else f'{int(mins)} m'
|
|
190
209
|
|
|
191
210
|
@staticmethod
|
|
192
|
-
def
|
|
211
|
+
def _format_timing_msg(
|
|
193
212
|
func_name: str,
|
|
194
213
|
args: tuple,
|
|
195
214
|
kwargs: dict,
|
|
@@ -197,6 +216,21 @@ class Timer:
|
|
|
197
216
|
iterations: int,
|
|
198
217
|
verbose: bool,
|
|
199
218
|
) -> str:
|
|
200
|
-
|
|
219
|
+
"""Formats a concise timing message for a decorated function call.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
func_name (str): Name of the function being measured.
|
|
223
|
+
args (tuple): Positional arguments passed to the function.
|
|
224
|
+
kwargs (dict): Keyword arguments passed to the function.
|
|
225
|
+
duration_str (str): Formatted duration string (already unit-scaled).
|
|
226
|
+
iterations (int): Number of timing iterations used for averaging.
|
|
227
|
+
verbose (bool): Whether to include function arguments in the message.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
str: A formatted summary string, for example:
|
|
231
|
+
'process_data took 12.31 ms (avg. over 10 runs)'
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
extra_info = f'{args!r} {kwargs!r} ' if verbose else ''
|
|
201
235
|
iter_info = f' (avg. over {iterations} runs)' if iterations > 1 else ''
|
|
202
236
|
return f'{func_name} {extra_info}took {duration_str}{iter_info}'
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nano_dev_utils
|
|
3
|
+
Version: 1.5.2
|
|
4
|
+
Summary: A collection of small Python utilities for developers.
|
|
5
|
+
Project-URL: Homepage, https://github.com/yaronday/nano_utils
|
|
6
|
+
Project-URL: Issues, https://github.com/yaronday/nano_utils/issues
|
|
7
|
+
Project-URL: license, https://github.com/yaronday/nano_dev_utils/blob/master/LICENSE
|
|
8
|
+
Author-email: Yaron Dayan <yaronday77@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: build>=1.3.0
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# nano_dev_utils
|
|
18
|
+
|
|
19
|
+
A collection of small Python utilities for developers.
|
|
20
|
+
[PYPI package: nano-dev-utils](https://pypi.org/project/nano-dev-utils)
|
|
21
|
+
|
|
22
|
+
## Modules
|
|
23
|
+
|
|
24
|
+
### `timers.py`
|
|
25
|
+
|
|
26
|
+
This module provides a `Timer` class for measuring the execution time of code blocks and functions with additional features like timeout control and multi-iteration averaging.
|
|
27
|
+
|
|
28
|
+
#### `Timer` Class
|
|
29
|
+
|
|
30
|
+
* **`__init__(self, precision: int = 4, verbose: bool = False, printout: bool = False)`**: Initializes a `Timer` instance.
|
|
31
|
+
* `precision`: The number of decimal places to record and display time durations. Defaults to 4.
|
|
32
|
+
* `verbose`: Optionally displays the function's positional arguments (args) and keyword arguments (kwargs). Defaults to `False`.
|
|
33
|
+
* `printout`: Allows printing to console.
|
|
34
|
+
|
|
35
|
+
* **`def timeit(
|
|
36
|
+
self,
|
|
37
|
+
iterations: int = 1,
|
|
38
|
+
timeout: float | None = None,
|
|
39
|
+
per_iteration: bool = False,
|
|
40
|
+
) -> Callable[[Callable[P, Any]], Callable[P, Any]]:`**:
|
|
41
|
+
Decorator that times either **sync** or **async** function execution with advanced features:
|
|
42
|
+
* `iterations`: Number of times to run the function (for averaging). Defaults to 1.
|
|
43
|
+
* `timeout`: Maximum allowed execution time in seconds. When exceeded:
|
|
44
|
+
* Raises `TimeoutError` immediately
|
|
45
|
+
* **Warning:** The function execution will be aborted mid-operation
|
|
46
|
+
* No return value will be available if timeout occurs
|
|
47
|
+
* `per_iteration`: If True, applies timeout check to each iteration; otherwise checks total time across all iterations.
|
|
48
|
+
* Features:
|
|
49
|
+
* Records execution times
|
|
50
|
+
* Handles timeout conditions
|
|
51
|
+
* Calculates average execution time across iterations
|
|
52
|
+
* Logs the function name and execution time (with optional arguments)
|
|
53
|
+
* Returns the result of the original function (unless timeout occurs)
|
|
54
|
+
|
|
55
|
+
#### Example Usage:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
import time
|
|
59
|
+
import logging
|
|
60
|
+
from nano_dev_utils import timer
|
|
61
|
+
|
|
62
|
+
# if printout is not enabled, a logger must be configured in order to see timing results
|
|
63
|
+
logging.basicConfig(filename='timer example.log',
|
|
64
|
+
level=logging.INFO, # DEBUG, WARNING, ERROR, CRITICAL
|
|
65
|
+
format='%(asctime)s - %(levelname)s: %(message)s',
|
|
66
|
+
datefmt='%d-%m-%Y %H:%M:%S')
|
|
67
|
+
|
|
68
|
+
# Basic timing
|
|
69
|
+
@timer.timeit()
|
|
70
|
+
def my_function(a, b=10):
|
|
71
|
+
"""A sample function."""
|
|
72
|
+
time.sleep(0.1)
|
|
73
|
+
return a + b
|
|
74
|
+
|
|
75
|
+
timer.init(precision=6, verbose=True)
|
|
76
|
+
'''Alternative options:
|
|
77
|
+
timer.update({'precision': 6, 'verbose': True}) # 1. Using update method
|
|
78
|
+
|
|
79
|
+
from nano_dev_utils.timers import Timer # 2. explicit instantiation
|
|
80
|
+
timer = Timer(precision=6, verbose=True)
|
|
81
|
+
'''
|
|
82
|
+
|
|
83
|
+
timer.update({'printout': True}) # allow printing to console
|
|
84
|
+
|
|
85
|
+
# Advanced usage with timeout and iterations
|
|
86
|
+
@timer.timeit(iterations=5, timeout=0.5, per_iteration=True)
|
|
87
|
+
def critical_function(x):
|
|
88
|
+
"""Function with timeout check per iteration."""
|
|
89
|
+
time.sleep(0.08)
|
|
90
|
+
return x * 2
|
|
91
|
+
|
|
92
|
+
result1 = my_function(5, b=20) # Shows args/kwargs and timing
|
|
93
|
+
result2 = critical_function(10) # Runs 5 times with per-iteration timeout
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### `dynamic_importer.py`
|
|
97
|
+
|
|
98
|
+
This module provides an `Importer` class for lazy loading and caching module imports.
|
|
99
|
+
|
|
100
|
+
#### `Importer` Class
|
|
101
|
+
|
|
102
|
+
* **`__init__(self)`**: Initializes an `Importer` instance with an empty dictionary `imported_modules` to cache imported modules.
|
|
103
|
+
|
|
104
|
+
* **`import_mod_from_lib(self, library: str, module_name: str) -> ModuleType | Any`**: Lazily imports a module from a specified library and caches it.
|
|
105
|
+
* `library` (str): The name of the library (e.g., "os", "requests").
|
|
106
|
+
* `module_name` (str): The name of the module to import within the library (e.g., "path", "get").
|
|
107
|
+
* Returns the imported module. If the module has already been imported, it returns a cached instance.
|
|
108
|
+
* Raises `ImportError` if the module cannot be found.
|
|
109
|
+
|
|
110
|
+
#### Example Usage:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from nano_dev_utils import importer
|
|
114
|
+
|
|
115
|
+
os_path = importer.import_mod_from_lib("os", "path")
|
|
116
|
+
print(f"Imported os.path: {os_path}")
|
|
117
|
+
|
|
118
|
+
requests_get = importer.import_mod_from_lib("requests", "get")
|
|
119
|
+
print(f"Imported requests.get: {requests_get}")
|
|
120
|
+
|
|
121
|
+
# Subsequent calls will return the cached module
|
|
122
|
+
os_path_again = importer.import_mod_from_lib("os", "path")
|
|
123
|
+
print(f"Imported os.path again (cached): {os_path_again}")
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### `release_ports.py`
|
|
127
|
+
|
|
128
|
+
This module provides a `PortsRelease` class to identify and release processes
|
|
129
|
+
listening on specified TCP ports.
|
|
130
|
+
It supports Windows, Linux, and macOS.
|
|
131
|
+
|
|
132
|
+
#### `PortsRelease` Class
|
|
133
|
+
|
|
134
|
+
* **`__init__(self, default_ports: list[int] | None = None)`**:
|
|
135
|
+
* Initializes a `PortsRelease` instance.
|
|
136
|
+
* `default_ports`: A list of default ports to manage. If not provided, it defaults to `[6277, 6274]`.
|
|
137
|
+
|
|
138
|
+
* **`get_pid_by_port(self, port: int) -> int | None`**: A static method that attempts to find
|
|
139
|
+
a process ID (PID) listening on a given `port`.
|
|
140
|
+
* It uses platform-specific commands (`netstat`, `ss`, `lsof`).
|
|
141
|
+
* Returns the PID if found, otherwise `None`.
|
|
142
|
+
|
|
143
|
+
* **`kill_process(self, pid: int) -> bool`**: A static method that attempts to kill the process
|
|
144
|
+
with the given `pid`.
|
|
145
|
+
* It uses platform-specific commands (`taskkill`, `kill -9`).
|
|
146
|
+
* Returns `True` if the process was successfully killed, `False` otherwise.
|
|
147
|
+
|
|
148
|
+
* **`release_all(self, ports: list[int] | None = None) -> None`**: Releases all processes listening on the specified `ports`.
|
|
149
|
+
* `ports`: A list of ports to release.
|
|
150
|
+
* If `None`, it uses the `default_ports` defined during initialization.
|
|
151
|
+
* For each port, it first tries to get the PID and then attempts to kill the process.
|
|
152
|
+
* It logs the actions and any errors encountered. Invalid port numbers in the provided list are skipped.
|
|
153
|
+
|
|
154
|
+
#### Example Usage:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
import logging
|
|
158
|
+
from nano_dev_utils import ports_release, PortsRelease
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
logging.basicConfig(filename='port release.log',
|
|
162
|
+
level=logging.INFO, # DEBUG, WARNING, ERROR, CRITICAL
|
|
163
|
+
format='%(asctime)s - %(levelname)s: %(message)s',
|
|
164
|
+
datefmt='%d-%m-%Y %H:%M:%S')
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
ports_release.release_all()
|
|
168
|
+
|
|
169
|
+
# Create an instance with custom ports
|
|
170
|
+
custom_ports_releaser = PortsRelease(default_ports=[8080, 9000, 6274])
|
|
171
|
+
custom_ports_releaser.release_all(ports=[8080, 9000])
|
|
172
|
+
|
|
173
|
+
# Release only the default ports
|
|
174
|
+
ports_release.release_all()
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## `file_tree_display.py`
|
|
178
|
+
|
|
179
|
+
This module provides a utility for generating a visually structured directory tree.
|
|
180
|
+
It supports recursive traversal, customizable hierarchy styles, and inclusion / exclusion
|
|
181
|
+
patterns for directories and files.
|
|
182
|
+
Output can be displayed in the console or saved to a file.
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
#### Key Features
|
|
186
|
+
|
|
187
|
+
- Recursively displays and logs directory trees
|
|
188
|
+
- Efficient directory traversal
|
|
189
|
+
- Blazing fast (see Benchmarks below)
|
|
190
|
+
- Generates human-readable file tree structure
|
|
191
|
+
- Supports including / ignoring specific directories or files via pattern matching
|
|
192
|
+
- Customizable tree display output
|
|
193
|
+
- Optionally saves the resulting tree to a text file
|
|
194
|
+
- Lightweight, flexible and easily configurable
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
### Benchmarks
|
|
198
|
+
|
|
199
|
+
The measurements were carried out on unfiltered folders containing multiple files und subdirectories, using SSD.
|
|
200
|
+
Avg. time was measured over 20 runs per configuration, using `timeit` decorator I've implemented in this package.
|
|
201
|
+
|
|
202
|
+
Comparing FileTreeDisplay (FTD) with
|
|
203
|
+
[win_tree_wrapper](https://github.com/yaronday/nano_dev_utils/blob/master/benchmark/win_tree_wrapper.py)
|
|
204
|
+
(Windows [tree](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/tree)
|
|
205
|
+
wrapper which I've implemented for this purpose).
|
|
206
|
+
[Benchtest code](https://github.com/yaronday/nano_dev_utils/blob/master/benchmark/benchtest.py)
|
|
207
|
+
|
|
208
|
+
### Performance Comparison — FTD vs.`tree`
|
|
209
|
+
|
|
210
|
+
<table>
|
|
211
|
+
<tr><th>Test Context </th><th>Results</th></tr>
|
|
212
|
+
<tr><td>
|
|
213
|
+
|
|
214
|
+
| Metric | Test1 | Test2 |
|
|
215
|
+
|:---------------------|:-------|:-----------|
|
|
216
|
+
| **Files** | 10553 | 138492 |
|
|
217
|
+
| **Folders** | 1235 | 20428 |
|
|
218
|
+
| **Wrapper Overhead** | ~30 ms | negligible |
|
|
219
|
+
|
|
220
|
+
</td><td>
|
|
221
|
+
|
|
222
|
+
| Tool | T1 (s) | T2 (s) | Relative Speed |
|
|
223
|
+
|:---------|:------:|:------:|:--------------:|
|
|
224
|
+
| **FTD** | 0.196 | 2.900* | — |
|
|
225
|
+
| **tree** | 0.390 | 5.018 | ~2x slower |
|
|
226
|
+
|
|
227
|
+
</td></tr> </table>
|
|
228
|
+
|
|
229
|
+
***Without sorting** FTD takes 162 ms and 2.338 s for Test1 and Test2, respectively.
|
|
230
|
+
FTD is roughly **1.7x–2.4x faster** than the native `tree` binary across both datasets.
|
|
231
|
+
|
|
232
|
+
### Brief Analysis
|
|
233
|
+
|
|
234
|
+
### I. Linear scaling as a function of entries
|
|
235
|
+
FTD performance scales almost perfectly linearly with total entries:
|
|
236
|
+
|
|
237
|
+
* **T1:** 10 k files → 0.2 s
|
|
238
|
+
* **T2:** 138 k files → 2.9 s
|
|
239
|
+
→ ~14x more files → ~15x more runtime => expected by linearity.
|
|
240
|
+
|
|
241
|
+
### II. Figuring out why `tree` is nearly 2 times slower than my FTD
|
|
242
|
+
Although `tree` is implemented in C, it incurs more I/O work:
|
|
243
|
+
* Performs full `lstat()` on each entry (permissions, timestamps, etc.).
|
|
244
|
+
* Prints incrementally to `stdout` → many system calls (syscalls).
|
|
245
|
+
* Handles color / formatting output.
|
|
246
|
+
|
|
247
|
+
My FTD avoids this by:
|
|
248
|
+
|
|
249
|
+
* Using `os.scandir()` (caching stat info).
|
|
250
|
+
* Filtering and sorting in-memory.
|
|
251
|
+
* Buffering output before optional print/write.
|
|
252
|
+
|
|
253
|
+
Result: lower syscall count and fewer I/O stalls.
|
|
254
|
+
|
|
255
|
+
### III. Python overhead is clearly negligible
|
|
256
|
+
Even at 2.9 s for ~160K entries, throughput ~55K entries/s — close to filesystem limits on SSDs.
|
|
257
|
+
Measured wrapper overhead (~30 ms) is < 1 % of total runtime.
|
|
258
|
+
|
|
259
|
+
### Key Insights
|
|
260
|
+
|
|
261
|
+
| Observation | Explanation |
|
|
262
|
+
|:-------------------------------|:----------------------------------------------------|
|
|
263
|
+
| **FTD ~2x faster than `tree`** | Avoids per-file printing and redundant stats. |
|
|
264
|
+
| **I/O-bound execution** | Filesystem metadata fetch dominates total time. |
|
|
265
|
+
| **Linear runtime scaling** | Recursive generator design adds no hidden overhead. |
|
|
266
|
+
| **Stable memory footprint** | Uses streaming generators and `StringIO` buffering. |
|
|
267
|
+
|
|
268
|
+
### Conclusions
|
|
269
|
+
|
|
270
|
+
* **FTD outperforms `tree` by roughly 2x** on both moderate and large datasets.
|
|
271
|
+
* **Runtime scales linearly** with total directory entries.
|
|
272
|
+
* **Python layer overhead is negligible** — performance is bounded by kernel I/O.
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
#### Class Overview
|
|
276
|
+
|
|
277
|
+
**`FileTreeDisplay`**
|
|
278
|
+
Constructs and manages the visual representation of a folder structure of a path or of a disk drive.
|
|
279
|
+
|
|
280
|
+
**Initialization Parameters**
|
|
281
|
+
|
|
282
|
+
| Parameter | Type | Description |
|
|
283
|
+
|:--------------------------------------|:------------------------------------|:---------------------------------------------------------------------------------|
|
|
284
|
+
| `root_dir` | `str` | Path to the directory to scan. |
|
|
285
|
+
| `filepath` | `str / None` | Optional output destination for the saved file tree. |
|
|
286
|
+
| `ignore_dirs` | `list[str] or set[str] or None` | Directory names or patterns to skip. |
|
|
287
|
+
| `ignore_files` | `list[str] or set[str] or None` | File names or patterns to skip. |
|
|
288
|
+
| `include_dirs` | `list[str] or set[str] or None` | Only include specified folder names or patterns. |
|
|
289
|
+
| `include_files` | `list[str] or set[str] or None` | Only include specified file names or patterns, '*.pdf' - only include pdfs. |
|
|
290
|
+
| `style` | `str` | Characters used to mark hierarchy levels. Defaults to `'classic'`. |
|
|
291
|
+
| `indent` | `int` | Number of style characters per level. Defaults `2`. |
|
|
292
|
+
| `files_first` | `bool` | Determines whether to list files first. Defaults to False. |
|
|
293
|
+
| `skip_sorting` | `bool` | Skip sorting directly, even if configured. |
|
|
294
|
+
| `sort_key_name` | `str` | Sort key. Lexicographic ('lex') or 'custom'. Defaults to 'natural'. |
|
|
295
|
+
| `reverse` | `bool` | Reversed sorting order. |
|
|
296
|
+
| `custom_sort` | `Callable[[str], Any] / None` | Custom sort key function. |
|
|
297
|
+
| `title` | `str` | Custom title shown in the output. |
|
|
298
|
+
| `save2file` | `bool` | Save file tree (folder structure) info into a file. |
|
|
299
|
+
| `printout` | `bool` | Print file tree info. |
|
|
300
|
+
|
|
301
|
+
#### Core Methods
|
|
302
|
+
|
|
303
|
+
- `file_tree_display(save2file: bool = True) -> str | None`
|
|
304
|
+
Generates the directory tree. If `save2file=True`, saves the output; otherwise prints it directly.
|
|
305
|
+
|
|
306
|
+
- `_build_tree(dir_path, *, prefix, style, sort_key,
|
|
307
|
+
files_first, dir_filter, file_filter, reverse, indent) -> Generator[str, None, None]`
|
|
308
|
+
Recursively traverses the directory tree in depth-first order (DFS) and yields formatted lines representing the file and folder structure.
|
|
309
|
+
|
|
310
|
+
| Parameter | Type | Description |
|
|
311
|
+
|-------------------------------------|-------------------------|------------------------------------------------------------------------------|
|
|
312
|
+
| **`dir_path`** | `str` | Path to the directory being traversed. |
|
|
313
|
+
| **`prefix`** | `str` | Current indentation prefix for nested entries. |
|
|
314
|
+
| **`style`** | `dict[str, str]` | Connector style mapping with keys: `branch`, `end`, `space`, and `vertical`. |
|
|
315
|
+
| **`sort_key`** | `Callable[[str], Any]` | Function used to sort directory and file names. |
|
|
316
|
+
| **`files_first`** | `bool` | If `True`, list files before subdirectories. |
|
|
317
|
+
| **`dir_filter`**, **`file_filter`** | `Callable[[str], bool]` | Predicates to include or exclude directories and files. |
|
|
318
|
+
| **`reverse`** | `bool` | If `True`, reverses the sort order. |
|
|
319
|
+
| **`indent`** | `int` | Number of spaces (or repeated characters) per indentation level. |
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
#### Example Usage
|
|
323
|
+
|
|
324
|
+
```python
|
|
325
|
+
from pathlib import Path
|
|
326
|
+
from nano_dev_utils.file_tree_display import FileTreeDisplay
|
|
327
|
+
|
|
328
|
+
root = r'c:/your_root_dir'
|
|
329
|
+
target_path = r'c:/your_target_path'
|
|
330
|
+
filename = 'filetree.md'
|
|
331
|
+
filepath = str(Path(target_path, filename))
|
|
332
|
+
|
|
333
|
+
ftd = FileTreeDisplay(root_dir=root,
|
|
334
|
+
ignore_dirs=['.git', 'node_modules', '.idea'],
|
|
335
|
+
ignore_files=['.gitignore', '*.toml'],
|
|
336
|
+
style='classic',
|
|
337
|
+
include_dirs=['src', 'tests', 'snapshots'],
|
|
338
|
+
filepath=filepath,
|
|
339
|
+
sort_key_name='custom',
|
|
340
|
+
custom_sort=(lambda x: any(ext in x.lower() for ext in ('jpg', 'png'))),
|
|
341
|
+
files_first=True,
|
|
342
|
+
reverse=True
|
|
343
|
+
)
|
|
344
|
+
ftd.file_tree_display()
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
#### Custom connector style
|
|
348
|
+
You can define and register your own connector styles at runtime by adding entries to style_dict:
|
|
349
|
+
|
|
350
|
+
```Python
|
|
351
|
+
from nano_dev_utils.file_tree_display import FileTreeDisplay
|
|
352
|
+
ftd = FileTreeDisplay(root_dir=".")
|
|
353
|
+
ftd.style_dict["plus2"] = ftd.connector_styler("+-- ", "+== ")
|
|
354
|
+
ftd.style = "plus2"
|
|
355
|
+
ftd.printout = True
|
|
356
|
+
ftd.file_tree_display()
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
#### Error Handling
|
|
361
|
+
|
|
362
|
+
The module raises well-defined exceptions for common issues:
|
|
363
|
+
|
|
364
|
+
- `NotADirectoryError` when the path is not a directory
|
|
365
|
+
- `PermissionError` for unreadable directories or write-protected files
|
|
366
|
+
- `OSError` for general I/O or write failures
|
|
367
|
+
|
|
368
|
+
***
|
|
369
|
+
|
|
370
|
+
## License
|
|
371
|
+
This project is licensed under the MIT License.
|
|
372
|
+
See [LICENSE](https://github.com/yaronday/nano_dev_utils/blob/master/LICENSE) for details.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
nano_dev_utils/__init__.py,sha256=bJNCUyssMVyNmOey-god8A2kElC4nCR9B5DsdvUrKWw,1014
|
|
2
|
+
nano_dev_utils/common.py,sha256=RfTmJbTLwF8ihN7FjxSKDDAwJFFFdFi66M1jbdyIcd8,5985
|
|
3
|
+
nano_dev_utils/dynamic_importer.py,sha256=-Mh76366lI_mP2QA_jxiVfcKCHOHeukS_j4v7fTh0xw,1028
|
|
4
|
+
nano_dev_utils/file_tree_display.py,sha256=FH3ipF48_hiS6gtpW8iLHwtTomVjZ4xNvlMNZdYHrv0,11195
|
|
5
|
+
nano_dev_utils/release_ports.py,sha256=yLWMMbN6j6kWtGTg-Nynn37-Q4b2rxkls9hs2sqeZjA,6081
|
|
6
|
+
nano_dev_utils/timers.py,sha256=jR3PwYB40CTqxcfEuwxSZvQc3wMUuqRy1g__0tPXjz0,8783
|
|
7
|
+
nano_dev_utils-1.5.2.dist-info/METADATA,sha256=Ao9EkSermacne4uHklmH_6EvgfQmP-__RkRsaCDGy_c,17607
|
|
8
|
+
nano_dev_utils-1.5.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
9
|
+
nano_dev_utils-1.5.2.dist-info/licenses/LICENSE,sha256=Muenl7Bw_LdtHZtlOMAP7Kt97gDCq8WWp2605eDWhHU,1089
|
|
10
|
+
nano_dev_utils-1.5.2.dist-info/RECORD,,
|