nano-dev-utils 1.0.0__py3-none-any.whl → 1.4.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.

Potentially problematic release.


This version of nano-dev-utils might be problematic. Click here for more details.

@@ -2,10 +2,19 @@
2
2
  Copyright (c) 2025 Yaron Dayan
3
3
  """
4
4
 
5
+ from pathlib import Path
6
+ from importlib.metadata import version
5
7
  from .dynamic_importer import Importer
6
8
  from .timers import Timer
7
9
  from .release_ports import PortsRelease, PROXY_SERVER, INSPECTOR_CLIENT
8
- from importlib.metadata import version
10
+ from .common import update, encode_dict, str2file, PredicateBuilder, FilterSet
11
+ from .file_tree_display import FileTreeDisplay, DEFAULT_SFX
12
+
13
+ timer = Timer()
14
+ ports_release = PortsRelease()
15
+ importer = Importer()
16
+ filetree_display = FileTreeDisplay(root_dir=str(Path.cwd()))
17
+ predicate_builder = PredicateBuilder
9
18
 
10
19
  __version__ = version('nano-dev-utils')
11
20
 
@@ -15,4 +24,15 @@ __all__ = [
15
24
  'PortsRelease',
16
25
  'PROXY_SERVER',
17
26
  'INSPECTOR_CLIENT',
27
+ 'update',
28
+ 'encode_dict',
29
+ 'str2file',
30
+ 'PredicateBuilder',
31
+ 'predicate_builder',
32
+ 'FilterSet',
33
+ 'timer',
34
+ 'ports_release',
35
+ 'importer',
36
+ 'filetree_display',
37
+ 'DEFAULT_SFX',
18
38
  ]
@@ -0,0 +1,147 @@
1
+ import fnmatch
2
+ import re
3
+
4
+ from pathlib import Path
5
+ from typing import AnyStr
6
+
7
+ from collections.abc import Callable
8
+ from functools import partial
9
+ from typing import TypeAlias
10
+
11
+
12
+ FilterSet: TypeAlias = list[str] | set[str] | None
13
+
14
+
15
+ def update(obj: object, attrs: dict) -> None:
16
+ """Updates an object's attributes from a dictionary.
17
+ Uses direct __dict__ modification if possible for performance,
18
+ otherwise falls back to setattr for objects without __dict__ (e.g., __slots__).
19
+
20
+ Args:
21
+ obj: The object whose attributes will be updated.
22
+ attrs: Dictionary of attribute names and values.
23
+
24
+ Raises:
25
+ AttributeError: If an attribute cannot be set (optional, see notes).
26
+ """
27
+ if hasattr(obj, '__dict__'):
28
+ obj.__dict__.update(attrs)
29
+ else:
30
+ for key, value in attrs.items():
31
+ try:
32
+ setattr(obj, key, value)
33
+ except AttributeError as e:
34
+ raise AttributeError(
35
+ f"Cannot set attribute '{key}' on object '{obj}': {e}"
36
+ )
37
+
38
+
39
+ def encode_dict(input_dict: dict) -> bytes:
40
+ """
41
+ Encodes the values of a dictionary into a single bytes object.
42
+
43
+ Each value in the dictionary is converted to its string representation, encoded as bytes,
44
+ and concatenated together with a single space (b' ') separator.
45
+
46
+ Parameters:
47
+ input_dict (dict): The dictionary whose values are to be encoded.
48
+
49
+ Returns:
50
+ bytes: A single bytes object containing all values, separated by spaces.
51
+
52
+ Example:
53
+ >>> encode_dict({"a": 1, "b": "test"})
54
+ b'1 test'
55
+
56
+ Raises:
57
+ TypeError: If input_dict is not a dictionary.
58
+ """
59
+ if not isinstance(input_dict, dict):
60
+ raise TypeError('input_dict must be a dictionary.')
61
+ return b' '.join(str(v).encode() for v in input_dict.values())
62
+
63
+
64
+ def str2file(content: AnyStr, filepath: str, mode: str = 'w', enc: str = 'utf-8') -> None:
65
+ """Simply save file directly from any string content.
66
+
67
+ Args:
68
+ content (AnyStr): String or bytes to write. Must match the mode type ,e.g. bytes for binary.
69
+ filepath (str): Full file path to write to.
70
+ mode (str): see doc for Path.open. Defaults to 'w'.
71
+ enc (str): Encoding used in text modes; ignored in binary modes. Defaults to 'utf-8'.
72
+ """
73
+ out_file_path = Path(filepath)
74
+ try:
75
+ if 'b' in mode:
76
+ with out_file_path.open(mode) as f:
77
+ f.write(content)
78
+ else:
79
+ with out_file_path.open(mode, encoding=enc) as f:
80
+ f.write(content)
81
+
82
+ except PermissionError as e:
83
+ raise PermissionError(f"Cannot write to '{out_file_path}': {e}")
84
+ except OSError as e:
85
+ raise OSError(f"Error writing file '{out_file_path}': {e}")
86
+
87
+
88
+ class PredicateBuilder:
89
+ def build_predicate(self, allow: FilterSet, block: FilterSet) -> Callable[[str], bool]:
90
+ """Build a memory-efficient predicate function."""
91
+ compile_patts = self.compile_patts
92
+
93
+ allow_lits, allow_patts = compile_patts(allow)
94
+ block_lits, block_patts = compile_patts(block)
95
+
96
+ flag = (1 if allow_lits or allow_patts else 0,
97
+ 1 if block_lits or block_patts else 0)
98
+
99
+ match flag: # (allow, block)
100
+ case (0, 0):
101
+ return lambda name: True
102
+
103
+ case (0, 1):
104
+ return partial(self._match_patt_with_lits,
105
+ name_patts=block_patts, name_lits=block_lits, negate=True)
106
+
107
+ case (1, 0):
108
+ return partial(self._match_patt_with_lits, name_patts=allow_patts,
109
+ name_lits=allow_lits, negate=False)
110
+
111
+ case (1, 1):
112
+ return partial(self._allow_block_predicate,
113
+ allow_lits=allow_lits, allow_patts=allow_patts,
114
+ block_lits=block_lits, block_patts=block_patts)
115
+
116
+ @staticmethod
117
+ def compile_patts(fs: FilterSet) -> tuple[set[str], list[re.Pattern]]:
118
+ if not fs:
119
+ return set(), []
120
+ literals, patterns = set(), []
121
+ for item in fs:
122
+ if "*" in item or "?" in item or "[" in item:
123
+ patterns.append(re.compile(fnmatch.translate(item)))
124
+ else:
125
+ literals.add(item)
126
+ return literals, patterns
127
+
128
+ @staticmethod
129
+ def _match_patts(name: str, patterns: list[re.Pattern]) -> bool:
130
+ """Return True if name matches any compiled regex pattern."""
131
+ return any(pat.fullmatch(name) for pat in patterns)
132
+
133
+ def _match_patt_with_lits(self, name: str, *, name_lits: set[str],
134
+ name_patts: list[re.Pattern], negate: bool = False) -> bool:
135
+ """Return True if name is in literals or matches any pattern."""
136
+ res = name in name_lits or self._match_patts(name, name_patts)
137
+ return not res if negate else res
138
+
139
+ def _allow_block_predicate(self, name: str,
140
+ *, allow_lits: set[str], allow_patts: list[re.Pattern],
141
+ block_lits: set[str], block_patts: list[re.Pattern]) -> bool:
142
+ """Return True if name is allowed and not blocked (block takes precedence)."""
143
+ if name in block_lits or self._match_patts(name, block_patts):
144
+ return False
145
+ if name in allow_lits or self._match_patts(name, allow_patts):
146
+ return True
147
+ return False
@@ -2,12 +2,16 @@ from types import ModuleType
2
2
  from typing import Any
3
3
 
4
4
  import importlib
5
+ from nano_dev_utils.common import update
5
6
 
6
7
 
7
8
  class Importer:
8
9
  def __init__(self):
9
10
  self.imported_modules = {}
10
11
 
12
+ def update(self, attrs: dict) -> None:
13
+ update(self, attrs)
14
+
11
15
  def import_mod_from_lib(self, library: str, module_name: str) -> ModuleType | Any:
12
16
  """Lazily imports and caches a specific submodule from a given library.
13
17
  :param library: The name of the library.
@@ -0,0 +1,209 @@
1
+ import os
2
+ import re
3
+
4
+ from collections.abc import Generator
5
+ from pathlib import Path
6
+ from typing_extensions import LiteralString, Callable, Any
7
+
8
+ from .common import str2file, FilterSet, PredicateBuilder
9
+
10
+
11
+ DEFAULT_SFX = '_filetree.txt'
12
+
13
+ STYLES: list[str] = [' ', '-', '—', '_', '*', '>', '<', '+', '.']
14
+
15
+ _NUM_SPLIT = re.compile(r'(\d+)').split
16
+
17
+
18
+ class FileTreeDisplay:
19
+ """Generate and display a visual file tree of a directory.
20
+
21
+ This class builds a directory tree structure and yields formatted
22
+ visual representations of directories and files.
23
+ Supports exclusion lists, configurable indentation, and custom prefix styles.
24
+ """
25
+ def __init__(
26
+ self,
27
+ root_dir: str | None = None,
28
+ filepath: str | None = None,
29
+ ignore_dirs: FilterSet = None,
30
+ ignore_files: FilterSet = None,
31
+ include_dirs: FilterSet = None,
32
+ include_files: FilterSet = None,
33
+ style: str = ' ',
34
+ indent: int = 2,
35
+ files_first: bool = False,
36
+ sort_key_name: str = 'natural',
37
+ reverse: bool = False,
38
+ custom_sort: Callable[[str], Any] | None = None,
39
+ save2file: bool = True,
40
+ printout: bool = False,
41
+ ) -> None:
42
+ """Initialize the FileTreeDisplay instance.
43
+
44
+ Args:
45
+ root_dir (str): Root directory to traverse.
46
+ filepath: str | None: full output file path.
47
+ ignore_dirs (list[str] | set[str] | None): Directory names or patterns to ignore.
48
+ ignore_files (list[str] | set[str] | None): File names or patterns to ignore.
49
+ include_dirs (list[str] | set[str] | None): Directory names or patterns to include.
50
+ include_files (list[str] | set[str] | None): File names or patterns to include.
51
+ style (str): Character(s) used to represent hierarchy levels. Defaults to " ".
52
+ indent (int): Number of style characters used per hierarchy level. Defaults to 2.
53
+ files_first (bool): Determines whether to list files first. Defaults to False.
54
+ sort_key_name (str): sorting key name, e.g. 'lex' for lexicographic or 'custom'. Defaults to 'natural'.
55
+ '' means no sorting.
56
+ reverse (bool): reversed sorting.
57
+ custom_sort (Callable[[str], Any] | None):
58
+ save2file (bool): save file tree info to a file.
59
+ printout (bool): print file tree info.
60
+ """
61
+ self.root_path = Path(root_dir) if root_dir else Path.cwd()
62
+ self.filepath = filepath
63
+ self.ignore_dirs = set(ignore_dirs or [])
64
+ self.ignore_files = set(ignore_files or [])
65
+ self.include_dirs = set(include_dirs or [])
66
+ self.include_files = set(include_files or [])
67
+ self.style = style
68
+ self.indent = indent
69
+ self.files_first = files_first
70
+ self.sort_key_name = sort_key_name
71
+ self.reverse = reverse
72
+ self.custom_sort = custom_sort
73
+ self.save2file = save2file
74
+ self.printout = printout
75
+
76
+ self.sort_keys = {
77
+ 'natural': self._nat_key,
78
+ 'lex': self._lex_key,
79
+ 'custom': self.custom_sort,
80
+ '': None,
81
+ }
82
+
83
+ self.pb = PredicateBuilder()
84
+ self.dir_filter = self.pb.build_predicate(self.include_dirs, self.ignore_dirs)
85
+ self.file_filter = self.pb.build_predicate(self.include_files, self.ignore_files)
86
+
87
+ def init(self, *args, **kwargs) -> None:
88
+ self.__init__(*args, **kwargs)
89
+
90
+ def update(self, attrs: dict) -> None:
91
+ self.__dict__.update(attrs)
92
+ pattern = re.compile(r'^(ign|inc)')
93
+ if any(pattern.match(key) for key in attrs):
94
+ self.update_predicates()
95
+
96
+ def update_predicates(self):
97
+ self.dir_filter = self.pb.build_predicate(self.include_dirs, self.ignore_dirs)
98
+ self.file_filter = self.pb.build_predicate(self.include_files, self.ignore_files)
99
+
100
+ @staticmethod
101
+ def _nat_key(name: str) -> list[int | LiteralString]:
102
+ """Natural sorting key"""
103
+ return [int(part) if part.isdigit() else part.lower()
104
+ for part in _NUM_SPLIT(name)]
105
+
106
+ @staticmethod
107
+ def _lex_key(name: str) -> str:
108
+ """Lexicographic sorting key"""
109
+ return name.lower()
110
+
111
+ def file_tree_display(self) -> str:
112
+ """Generate and save the directory tree to a text file.
113
+
114
+ Returns:
115
+ Either a str: Path to the saved output file containing the directory tree.
116
+ or the whole built tree, as a string of CRLF-separated lines.
117
+ """
118
+ root_path_str = str(self.root_path)
119
+ filepath = self.filepath
120
+ if not self.root_path.is_dir():
121
+ raise NotADirectoryError(f"The path '{root_path_str}' is not a directory.")
122
+
123
+ if self.style not in STYLES:
124
+ raise ValueError(f"'{self.style}' is invalid: must be one of {STYLES}\n")
125
+
126
+ iterator = self.build_tree(root_path_str)
127
+
128
+ tree_info = self.get_tree_info(iterator)
129
+
130
+ if self.save2file:
131
+ str2file(tree_info, filepath)
132
+ return filepath
133
+
134
+ if self.printout:
135
+ print(tree_info)
136
+
137
+ return tree_info
138
+
139
+ def get_tree_info(self, iterator: Generator[str, None, None]) -> str:
140
+ lines = [f'{self.root_path.name}/']
141
+ lines.extend(list(iterator))
142
+ return '\n'.join(lines)
143
+
144
+ def build_tree(self, dir_path: str,
145
+ prefix: str = '') -> Generator[str, None, None]:
146
+ """Yields formatted directory tree lines, using a recursive DFS.
147
+ Intended order of appearance is with a preference to subdirectories first.
148
+
149
+ Args:
150
+ dir_path (str): The directory path currently being traversed.
151
+ prefix (str): Hierarchical prefix applied to each level.
152
+
153
+ Yields:
154
+ str: A formatted string representing either a directory or a file.
155
+ """
156
+ files_first = self.files_first
157
+ dir_filter, file_filter = self.dir_filter, self.file_filter
158
+ sort_key_name, reverse = self.sort_key_name, self.reverse
159
+ sort_key = self.sort_keys.get(self.sort_key_name)
160
+ curr_indent = self.style * self.indent
161
+
162
+ next_prefix = prefix + curr_indent
163
+
164
+ if sort_key is None:
165
+ if sort_key_name == 'custom':
166
+ raise ValueError("custom_sort function must be specified"
167
+ " when sort_key_name='custom'")
168
+ raise ValueError(f"Invalid sort key name: {sort_key_name}")
169
+
170
+ try:
171
+ with os.scandir(dir_path) as entries:
172
+ dirs, files = [], []
173
+ append_dir, append_file = dirs.append, files.append
174
+ for entry in entries:
175
+ name = entry.name
176
+ if entry.is_dir():
177
+ if dir_filter(name):
178
+ append_dir((name, entry.path))
179
+ else:
180
+ if file_filter(name):
181
+ append_file(name)
182
+
183
+ except (PermissionError, OSError) as e:
184
+ msg = '[Permission Denied]' if isinstance(e, PermissionError) else '[Error reading directory]'
185
+ yield f'{next_prefix}{msg}'
186
+ return
187
+
188
+ if sort_key:
189
+ dirs.sort(key=lambda d: sort_key(d[0]), reverse=reverse)
190
+ files.sort(key=sort_key, reverse=reverse)
191
+
192
+ if files_first:
193
+ for name in files:
194
+ yield next_prefix + name
195
+
196
+ for name, path in dirs:
197
+ yield f'{next_prefix}{name}/'
198
+ yield from self.build_tree(path, next_prefix)
199
+
200
+ if not files_first:
201
+ for name in files:
202
+ yield next_prefix + name
203
+
204
+ def format_out_path(self) -> Path:
205
+ alt_file_name = f'{self.root_path.name}{DEFAULT_SFX}'
206
+ out_file = (
207
+ Path(self.filepath) if self.filepath else (self.root_path / alt_file_name)
208
+ )
209
+ return out_file
@@ -2,6 +2,9 @@ import platform
2
2
  import subprocess
3
3
  import logging
4
4
 
5
+ from .common import update
6
+
7
+
5
8
  lgr = logging.getLogger(__name__)
6
9
  """Module-level logger. Configure using logging.basicConfig() in your application."""
7
10
 
@@ -60,6 +63,12 @@ class PortsRelease:
60
63
  def _log_unsupported_os() -> str:
61
64
  return f'Unsupported OS: {platform.system()}'
62
65
 
66
+ def init(self, *args, **kwargs) -> None:
67
+ self.__init__(*args, **kwargs)
68
+
69
+ def update(self, attrs: dict) -> None:
70
+ update(self, attrs)
71
+
63
72
  def get_pid_by_port(self, port: int) -> int | None:
64
73
  """Gets the process ID (PID) listening on the specified port."""
65
74
  system = platform.system()
nano_dev_utils/timers.py CHANGED
@@ -1,6 +1,22 @@
1
1
  from functools import wraps
2
2
  import time
3
- from typing import Callable, ParamSpec, TypeVar
3
+ import logging
4
+ import inspect
5
+
6
+ from typing import (
7
+ TypeVar,
8
+ ParamSpec,
9
+ Callable,
10
+ Awaitable,
11
+ Any,
12
+ cast,
13
+ )
14
+
15
+ from nano_dev_utils.common import update
16
+
17
+
18
+ lgr = logging.getLogger(__name__)
19
+ """Module-level logger. Configure using logging.basicConfig() in your application."""
4
20
 
5
21
  P = ParamSpec('P')
6
22
  R = TypeVar('R')
@@ -10,59 +26,177 @@ class Timer:
10
26
  def __init__(self, precision: int = 4, verbose: bool = False):
11
27
  self.precision = precision
12
28
  self.verbose = verbose
13
- self.units = [(1e9, 's'), (1e6, 'ms'), (1e3, 'μs'), (1.0, 'ns')]
29
+
30
+ def init(self, *args, **kwargs) -> None:
31
+ self.__init__(*args, **kwargs)
32
+
33
+ def update(self, attrs: dict[str, Any]) -> None:
34
+ update(self, attrs)
14
35
 
15
36
  def timeit(
16
37
  self,
17
38
  iterations: int = 1,
18
39
  timeout: float | None = None,
19
40
  per_iteration: bool = False,
20
- ) -> Callable[[Callable[P, R]], Callable[P, R | None]]:
21
- """Decorator that times function execution with optional timeout support."""
22
-
23
- def decorator(func: Callable[P, R]) -> Callable[P, R | None]:
24
- @wraps(func)
25
- def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | None:
26
- total_elapsed_ns = 0
27
- result: R | None = None
28
-
29
- for i in range(1, iterations + 1):
30
- start_ns = time.perf_counter_ns()
31
- result = func(*args, **kwargs)
32
- duration_ns = time.perf_counter_ns() - start_ns
33
- total_elapsed_ns += duration_ns
34
-
35
- if timeout is not None:
36
- if per_iteration:
37
- duration_s = duration_ns / 1e9
38
- if duration_s > timeout:
39
- raise TimeoutError(
40
- f'{func.__name__} exceeded '
41
- f'{timeout:.{self.precision}f}s on '
42
- f'iteration {i} (took '
43
- f'{duration_s:.{self.precision}f}s)'
44
- )
45
- else:
46
- total_duration_s = total_elapsed_ns / 1e9
47
- if total_duration_s > timeout:
48
- raise TimeoutError(
49
- f'{func.__name__} exceeded {timeout:.{self.precision}f}s '
50
- f'after {i} iterations (took {total_duration_s:.{self.precision}f}s)'
51
- )
52
-
53
- avg_elapsed_ns = total_elapsed_ns / iterations
54
- value, unit = next(
55
- (avg_elapsed_ns / div, u)
56
- for div, u in self.units
57
- if avg_elapsed_ns >= div or u == 'ns'
41
+ ) -> Callable[[Callable[P, Any]], Callable[P, Any]]:
42
+ """Decorator that measures execution time for sync / async functions.
43
+
44
+ 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.
48
+
49
+ Returns:
50
+ A decorated function that behaves identically to the original, with timing logged.
51
+ """
52
+
53
+ RP = ParamSpec('RP')
54
+ RR = TypeVar('RR')
55
+
56
+ precision = self.precision
57
+
58
+ def decorator(
59
+ func: Callable[RP, RR] | Callable[RP, Awaitable[RR]],
60
+ ) -> Callable[RP, Any]:
61
+ verbose = self.verbose
62
+ if inspect.iscoroutinefunction(func):
63
+ async_func = cast(Callable[RP, Awaitable[RR]], func)
64
+
65
+ @wraps(func)
66
+ async def async_wrapper(*args: RP.args, **kwargs: RP.kwargs) -> RR:
67
+ func_name = func.__name__
68
+ total_elapsed_ns = 0
69
+ result: RR | None = None
70
+ for i in range(1, iterations + 1):
71
+ start_ns = time.perf_counter_ns()
72
+ result = await async_func(*args, **kwargs)
73
+ duration_ns = time.perf_counter_ns() - start_ns
74
+ total_elapsed_ns += duration_ns
75
+
76
+ self._check_timeout(
77
+ func_name,
78
+ i,
79
+ duration_ns,
80
+ total_elapsed_ns,
81
+ timeout,
82
+ per_iteration,
83
+ )
84
+ avg_elapsed_ns = total_elapsed_ns / iterations
85
+ duration_str = self._duration_formatter(avg_elapsed_ns, precision)
86
+
87
+ msg = self._formatted_msg(
88
+ func_name, args, kwargs, duration_str, iterations, verbose
89
+ )
90
+ lgr.info(msg)
91
+ return cast(RR, result)
92
+
93
+ return cast(Callable[RP, Awaitable[RR]], async_wrapper)
94
+ else:
95
+ sync_func = cast(Callable[RP, RR], func)
96
+
97
+ @wraps(func)
98
+ def sync_wrapper(*args: RP.args, **kwargs: RP.kwargs) -> RR:
99
+ func_name = func.__name__
100
+ total_elapsed_ns = 0
101
+ result: RR | None = None
102
+ for i in range(1, iterations + 1):
103
+ start_ns = time.perf_counter_ns()
104
+ result = sync_func(*args, **kwargs)
105
+ duration_ns = time.perf_counter_ns() - start_ns
106
+ total_elapsed_ns += duration_ns
107
+ self._check_timeout(
108
+ func_name,
109
+ i,
110
+ duration_ns,
111
+ total_elapsed_ns,
112
+ timeout,
113
+ per_iteration,
114
+ )
115
+ avg_elapsed_ns = total_elapsed_ns / iterations
116
+ duration_str = self._duration_formatter(avg_elapsed_ns, precision)
117
+ msg = self._formatted_msg(
118
+ func_name, args, kwargs, duration_str, iterations, verbose
119
+ )
120
+ lgr.info(msg)
121
+ return cast(RR, result)
122
+
123
+ return cast(Callable[RP, RR], sync_wrapper)
124
+
125
+ return decorator
126
+
127
+ def _check_timeout(
128
+ self,
129
+ func_name: str,
130
+ i: int,
131
+ duration_ns: float,
132
+ total_elapsed_ns: float,
133
+ timeout: float | None,
134
+ per_iteration: bool,
135
+ ) -> None:
136
+ """Raise TimeoutError if timeout is exceeded."""
137
+ if timeout is None:
138
+ return
139
+ precision = self.precision
140
+ timeout_exceeded = f'{func_name} exceeded {timeout:.{precision}f}s'
141
+ if per_iteration:
142
+ duration_s = duration_ns / 1e9
143
+ if duration_s > timeout:
144
+ raise TimeoutError(
145
+ f'{timeout_exceeded} on iteration {i} '
146
+ f'(took {duration_s:.{precision}f}s)'
58
147
  )
59
- extra_info = f'{args} {kwargs} ' if self.verbose else ''
60
- iter_info = f' (avg. over {iterations} runs)' if iterations > 1 else ''
61
- print(
62
- f'{func.__name__} {extra_info}took {value:.{self.precision}f} [{unit}]{iter_info}'
148
+ else:
149
+ total_duration_s = total_elapsed_ns / 1e9
150
+ if total_duration_s > timeout:
151
+ raise TimeoutError(
152
+ f'{timeout_exceeded} after {i} iterations '
153
+ f'(took {total_duration_s:.{precision}f}s)'
63
154
  )
64
- return result
65
155
 
66
- return wrapper
156
+ @staticmethod
157
+ def _duration_formatter(elapsed_ns: float, precision: int = 4) -> str:
158
+ """Convert nanoseconds to the appropriate time unit, supporting multi-unit results."""
159
+ ns_sec, ns_min, ns_hour = 1e9, 6e10, 3.6e12
160
+ ns_ms, ns_us = 1e6, 1e3
67
161
 
68
- return decorator
162
+ if elapsed_ns < ns_sec:
163
+ if elapsed_ns >= ns_ms:
164
+ return f'{elapsed_ns / ns_ms:.{precision}f}ms'
165
+ elif elapsed_ns >= ns_us:
166
+ return f'{elapsed_ns / ns_us:.{precision}f}μs'
167
+ return f'{elapsed_ns:.2f}ns'
168
+
169
+ if elapsed_ns < ns_min:
170
+ seconds = elapsed_ns / ns_sec
171
+ return f'{seconds:.1f}s' if seconds < 10 else f'{seconds:.0f}s'
172
+
173
+ if elapsed_ns >= ns_hour:
174
+ hours = int(elapsed_ns / ns_hour)
175
+ rem = elapsed_ns % ns_hour
176
+ mins = int(rem / ns_min)
177
+ secs = int((rem % ns_min) / ns_sec)
178
+
179
+ parts = [f'{hours}h']
180
+ if mins:
181
+ parts.append(f'{mins}m')
182
+ if secs:
183
+ parts.append(f'{secs}s')
184
+ return ' '.join(parts)
185
+
186
+ else:
187
+ minutes = int(elapsed_ns / ns_min)
188
+ seconds = int((elapsed_ns % ns_min) / ns_sec)
189
+ return f'{minutes}m {seconds}s' if seconds else f'{minutes}m'
190
+
191
+ @staticmethod
192
+ def _formatted_msg(
193
+ func_name: str,
194
+ args: tuple,
195
+ kwargs: dict,
196
+ duration_str: str,
197
+ iterations: int,
198
+ verbose: bool,
199
+ ) -> str:
200
+ extra_info = f'{args} {kwargs} ' if verbose else ''
201
+ iter_info = f' (avg. over {iterations} runs)' if iterations > 1 else ''
202
+ return f'{func_name} {extra_info}took {duration_str}{iter_info}'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nano_dev_utils
3
- Version: 1.0.0
3
+ Version: 1.4.0
4
4
  Summary: A collection of small Python utilities for developers.
5
5
  Project-URL: Homepage, https://github.com/yaronday/nano_utils
6
6
  Project-URL: Issues, https://github.com/yaronday/nano_utils/issues
@@ -11,9 +11,7 @@ License-File: LICENSE
11
11
  Classifier: Operating System :: OS Independent
12
12
  Classifier: Programming Language :: Python :: 3
13
13
  Requires-Python: >=3.10
14
- Provides-Extra: test
15
- Requires-Dist: pytest-mock>=3.14.0; extra == 'test'
16
- Requires-Dist: pytest>=8.2.0; extra == 'test'
14
+ Requires-Dist: build>=1.3.0
17
15
  Description-Content-Type: text/markdown
18
16
 
19
17
  # nano_dev_utils
@@ -32,13 +30,13 @@ This module provides a `Timer` class for measuring the execution time of code bl
32
30
  * `precision`: The number of decimal places to record and display time durations. Defaults to 4.
33
31
  * `verbose`: Optionally displays the function's positional arguments (args) and keyword arguments (kwargs). Defaults to `False`.
34
32
 
35
- * **`timeit(
33
+ * **`def timeit(
36
34
  self,
37
35
  iterations: int = 1,
38
36
  timeout: float | None = None,
39
- per_iteration: bool = False
40
- ) -> Callable[[Callable[P, R]], Callable[P, R | None]]`**:
41
- Decorator that times function execution with advanced features:
37
+ per_iteration: bool = False,
38
+ ) -> Callable[[Callable[P, Any]], Callable[P, Any]]:`**:
39
+ Decorator that times either **sync** or **async** function execution with advanced features:
42
40
  * `iterations`: Number of times to run the function (for averaging). Defaults to 1.
43
41
  * `timeout`: Maximum allowed execution time in seconds. When exceeded:
44
42
  * Raises `TimeoutError` immediately
@@ -56,9 +54,7 @@ This module provides a `Timer` class for measuring the execution time of code bl
56
54
 
57
55
  ```python
58
56
  import time
59
- from nano_dev_utils.timers import Timer
60
-
61
- timer = Timer(precision=6, verbose=True)
57
+ from nano_dev_utils import timer
62
58
 
63
59
  # Basic timing
64
60
  @timer.timeit()
@@ -67,6 +63,18 @@ def my_function(a, b=10):
67
63
  time.sleep(0.1)
68
64
  return a + b
69
65
 
66
+ timer.init(precision=6, verbose=True)
67
+ '''
68
+ Alternatively we could have used the `update` method as well:
69
+
70
+ timer.update({'precision': 6, 'verbose': True})
71
+
72
+ The above config could be also achieved via explicit instantiation:
73
+
74
+ from nano_dev_utils.timers import Timer
75
+ timer = Timer(precision=6, verbose=True)
76
+ '''
77
+
70
78
  # Advanced usage with timeout and iterations
71
79
  @timer.timeit(iterations=5, timeout=0.5, per_iteration=True)
72
80
  def critical_function(x):
@@ -95,9 +103,7 @@ This module provides an `Importer` class for lazy loading and caching module imp
95
103
  #### Example Usage:
96
104
 
97
105
  ```python
98
- from nano_dev_utils.dynamic_importer import Importer
99
-
100
- importer = Importer()
106
+ from nano_dev_utils import importer
101
107
 
102
108
  os_path = importer.import_mod_from_lib("os", "path")
103
109
  print(f"Imported os.path: {os_path}")
@@ -142,7 +148,8 @@ It supports Windows, Linux, and macOS.
142
148
 
143
149
  ```python
144
150
  import logging
145
- from nano_dev_utils import PortsRelease
151
+ from nano_dev_utils import ports_release, PortsRelease
152
+
146
153
 
147
154
  # For configuration of logging level and format (supported already):
148
155
  logging.basicConfig(filename='port release.log',
@@ -150,18 +157,114 @@ logging.basicConfig(filename='port release.log',
150
157
  format='%(asctime)s - %(levelname)s: %(message)s',
151
158
  datefmt='%d-%m-%Y %H:%M:%S')
152
159
 
153
- # Create an instance with default ports
154
- port_releaser = PortsRelease()
155
- port_releaser.release_all()
160
+
161
+ ports_release.release_all()
156
162
 
157
163
  # Create an instance with custom ports
158
164
  custom_ports_releaser = PortsRelease(default_ports=[8080, 9000, 6274])
159
165
  custom_ports_releaser.release_all(ports=[8080, 9000])
160
166
 
161
167
  # Release only the default ports
162
- port_releaser.release_all()
168
+ ports_release.release_all()
169
+ ```
170
+
171
+ ### `file_tree_display.py`
172
+
173
+ This module provides a class-based utility for generating a visually structured directory tree.
174
+ It supports recursive traversal, customizable hierarchy styles, and exclusion patterns for directories and files.
175
+ Output can be displayed in the console or saved to a file.
176
+
177
+
178
+ #### Key Features
179
+
180
+ - Recursively displays and logs directory trees
181
+ - Efficient directory traversal
182
+ - Blazing fast (see Benchmarks below)
183
+ - Generates human-readable file tree structure
184
+ - Customizable tree display output
185
+ - Optionally saves the resulting tree to a text file
186
+ - Supports ignoring specific directories or files via pattern matching
187
+ - Handles permission and read/write errors gracefully
188
+
189
+ ## Benchmarks
190
+
191
+ As measured on a dataset of 10553 files, 1235 folders (ca. 16 GB) using Python 3.10 on SSD.
192
+ Avg. time was measured over 10 runs per configuration.
193
+
194
+ | Tool | Time (s) |
195
+ |-----------------|----------|
196
+ | FileTreeDisplay | 0.198 |
197
+ | Seedir | 4.378 |
198
+
199
+
200
+
201
+ #### Class Overview
202
+
203
+ **`FileTreeDisplay`**
204
+ Constructs and manages the visual representation of a directory structure.
205
+
206
+ **Initialization Parameters**
207
+
208
+ | Parameter | Type | Description |
209
+ |:---------------------------|:--------------------------------|:----------------------------------------------------------------------------|
210
+ | `root_dir` | `str` | Path to the directory to scan. |
211
+ | `filepath` | `str / None` | Optional output destination for the saved file tree. |
212
+ | `ignore_dirs` | `list[str] or set[str] or None` | Directory names or patterns to skip. |
213
+ | `ignore_files` | `list[str] or set[str] or None` | File names or patterns to skip. |
214
+ | `include_dirs` | `list[str] or set[str] or None` | Only include specified folder names or patterns. |
215
+ | `include_files` | `list[str] or set[str] or None` | Only include specified file names or patterns, '*.pdf' - only include pdfs. |
216
+ | `style` | `str` | Character(s) used to mark hierarchy levels. Defaults to `' '`. |
217
+ | `indent` | `int` | Number of style characters per level. Defaults `2`. |
218
+ | `files_first` | `bool` | Determines whether to list files first. Defaults to False. |
219
+ | `sort_key_name` | `str` | Sort key. 'lex' (lexicographic) or 'custom'. Defaults to 'natural'. |
220
+ | `reverse` | `bool` | Reversed sorting order. |
221
+ | `custom_sort` | `Callable[[str], Any] / None` | Custom sort key function. |
222
+ | `title` | `str` | Custom title shown in the output. |
223
+ | `save2file` | `bool` | Save file tree (folder structure) info into a file. |
224
+ | `printout` | `bool` | Print file tree info. |
225
+
226
+ #### Core Methods
227
+
228
+ - `file_tree_display(save2file: bool = True) -> str | None`
229
+ Generates the directory tree. If `save2file=True`, saves the output; otherwise prints it directly.
230
+ - `build_tree(dir_path: str, prefix: str = '') -> Generator[str, None, None]`
231
+ Recursively yields formatted lines representing directories and files.
232
+
233
+
234
+ #### Example Usage
235
+
236
+ ```python
237
+ from pathlib import Path
238
+ from nano_dev_utils.file_tree_display import FileTreeDisplay
239
+
240
+ root = r'c:/your_root_dir'
241
+ target_path = r'c:/your_target_path'
242
+ filename = 'filetree.md'
243
+ filepath = str(Path(target_path, filename))
244
+
245
+ ftd = FileTreeDisplay(root_dir=root,
246
+ ignore_dirs=['.git', 'node_modules', '.idea'],
247
+ ignore_files={'.gitignore', '*.toml'}, style='—',
248
+ include_dirs=['src', 'tests', 'snapshots'],
249
+ filepath=filepath,
250
+ sort_key_name='custom',
251
+ custom_sort=(lambda x: any(ext in x.lower() for ext in ('jpg', 'png'))),
252
+ reverse=True
253
+ )
254
+ ftd.file_tree_display()
163
255
  ```
164
256
 
257
+
258
+ #### Error Handling
259
+
260
+ The module raises well-defined exceptions for common issues:
261
+
262
+ - `NotADirectoryError` when the path is not a directory
263
+ - `PermissionError` for unreadable directories or write-protected files
264
+ - `OSError` for general I/O or write failures
265
+
266
+ ***
267
+
165
268
  ## License
166
269
  This project is licensed under the MIT License.
167
270
  See [LICENSE](LICENSE) for details.
@@ -0,0 +1,10 @@
1
+ nano_dev_utils/__init__.py,sha256=bJNCUyssMVyNmOey-god8A2kElC4nCR9B5DsdvUrKWw,1014
2
+ nano_dev_utils/common.py,sha256=bNgiaBP5kQUxfEUN3HYp1_TnwG-SRvsiqu67yAG5Y4o,5585
3
+ nano_dev_utils/dynamic_importer.py,sha256=-Mh76366lI_mP2QA_jxiVfcKCHOHeukS_j4v7fTh0xw,1028
4
+ nano_dev_utils/file_tree_display.py,sha256=0pfDr1QgyPJZbtl8mKNfJO0T3Jl2vUrwt8syVn_PhsI,8192
5
+ nano_dev_utils/release_ports.py,sha256=yLWMMbN6j6kWtGTg-Nynn37-Q4b2rxkls9hs2sqeZjA,6081
6
+ nano_dev_utils/timers.py,sha256=Ko2RR96-Sb6hIQAPxiwCUPAK2uwJ1dP_9Teym8lx_lo,7350
7
+ nano_dev_utils-1.4.0.dist-info/METADATA,sha256=z4PfsxOWteGXmLuJS5MtivqCKox9LR6wUKTjhelw9_c,11642
8
+ nano_dev_utils-1.4.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
9
+ nano_dev_utils-1.4.0.dist-info/licenses/LICENSE,sha256=Muenl7Bw_LdtHZtlOMAP7Kt97gDCq8WWp2605eDWhHU,1089
10
+ nano_dev_utils-1.4.0.dist-info/RECORD,,
@@ -1,8 +0,0 @@
1
- nano_dev_utils/__init__.py,sha256=imiI367TPj5s4IFmi-VrKJLbdkIxdIPISNscthoaS9U,454
2
- nano_dev_utils/dynamic_importer.py,sha256=cm2VwDYSGwhGZNO3uMX-O0LaKtEFtzkPm7BrZW4igG4,911
3
- nano_dev_utils/release_ports.py,sha256=sgmoPax9Hpcse1rHbBSnDJWTkvV6aWpZ5hQFxBKhGR8,5886
4
- nano_dev_utils/timers.py,sha256=dTbmf2O10YQw_Gz_fVATdZXqxgSpTjTlbeekd_jpLyw,2875
5
- nano_dev_utils-1.0.0.dist-info/METADATA,sha256=YOD8JC-1a-gaDHF5-VYu3Qtr5-uqHlsSorPguSZvk58,6498
6
- nano_dev_utils-1.0.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
7
- nano_dev_utils-1.0.0.dist-info/licenses/LICENSE,sha256=Muenl7Bw_LdtHZtlOMAP7Kt97gDCq8WWp2605eDWhHU,1089
8
- nano_dev_utils-1.0.0.dist-info/RECORD,,