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 CHANGED
@@ -61,7 +61,9 @@ def encode_dict(input_dict: dict) -> bytes:
61
61
  return b' '.join(str(v).encode() for v in input_dict.values())
62
62
 
63
63
 
64
- def str2file(content: AnyStr, filepath: str, mode: str = 'w', enc: str = 'utf-8') -> None:
64
+ def str2file(
65
+ content: AnyStr, filepath: str, mode: str = 'w', enc: str = 'utf-8'
66
+ ) -> None:
65
67
  """Simply save file directly from any string content.
66
68
 
67
69
  Args:
@@ -71,6 +73,10 @@ def str2file(content: AnyStr, filepath: str, mode: str = 'w', enc: str = 'utf-8'
71
73
  enc (str): Encoding used in text modes; ignored in binary modes. Defaults to 'utf-8'.
72
74
  """
73
75
  out_file_path = Path(filepath)
76
+
77
+ if not out_file_path.parent.exists():
78
+ out_file_path.parent.mkdir(parents=True, exist_ok=True)
79
+
74
80
  try:
75
81
  if 'b' in mode:
76
82
  with out_file_path.open(mode) as f:
@@ -86,32 +92,48 @@ def str2file(content: AnyStr, filepath: str, mode: str = 'w', enc: str = 'utf-8'
86
92
 
87
93
 
88
94
  class PredicateBuilder:
89
- def build_predicate(self, allow: FilterSet, block: FilterSet) -> Callable[[str], bool]:
95
+ def build_predicate(
96
+ self, allow: FilterSet, block: FilterSet
97
+ ) -> Callable[[str], bool]:
90
98
  """Build a memory-efficient predicate function."""
91
99
  compile_patts = self.compile_patts
92
100
 
93
101
  allow_lits, allow_patts = compile_patts(allow)
94
102
  block_lits, block_patts = compile_patts(block)
95
103
 
96
- flag = (1 if allow_lits or allow_patts else 0,
97
- 1 if block_lits or block_patts else 0)
104
+ flag = (
105
+ 1 if allow_lits or allow_patts else 0,
106
+ 1 if block_lits or block_patts else 0,
107
+ )
98
108
 
99
109
  match flag: # (allow, block)
100
110
  case (0, 0):
101
111
  return lambda name: True
102
112
 
103
113
  case (0, 1):
104
- return partial(self._match_patt_with_lits,
105
- name_patts=block_patts, name_lits=block_lits, negate=True)
114
+ return partial(
115
+ self._match_patt_with_lits,
116
+ name_patts=block_patts,
117
+ name_lits=block_lits,
118
+ negate=True,
119
+ )
106
120
 
107
121
  case (1, 0):
108
- return partial(self._match_patt_with_lits, name_patts=allow_patts,
109
- name_lits=allow_lits, negate=False)
122
+ return partial(
123
+ self._match_patt_with_lits,
124
+ name_patts=allow_patts,
125
+ name_lits=allow_lits,
126
+ negate=False,
127
+ )
110
128
 
111
129
  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)
130
+ return partial(
131
+ self._allow_block_predicate,
132
+ allow_lits=allow_lits,
133
+ allow_patts=allow_patts,
134
+ block_lits=block_lits,
135
+ block_patts=block_patts,
136
+ )
115
137
 
116
138
  @staticmethod
117
139
  def compile_patts(fs: FilterSet) -> tuple[set[str], list[re.Pattern]]:
@@ -119,7 +141,7 @@ class PredicateBuilder:
119
141
  return set(), []
120
142
  literals, patterns = set(), []
121
143
  for item in fs:
122
- if "*" in item or "?" in item or "[" in item:
144
+ if '*' in item or '?' in item or '[' in item:
123
145
  patterns.append(re.compile(fnmatch.translate(item)))
124
146
  else:
125
147
  literals.add(item)
@@ -130,15 +152,27 @@ class PredicateBuilder:
130
152
  """Return True if name matches any compiled regex pattern."""
131
153
  return any(pat.fullmatch(name) for pat in patterns)
132
154
 
133
- def _match_patt_with_lits(self, name: str, *, name_lits: set[str],
134
- name_patts: list[re.Pattern], negate: bool = False) -> bool:
155
+ def _match_patt_with_lits(
156
+ self,
157
+ name: str,
158
+ *,
159
+ name_lits: set[str],
160
+ name_patts: list[re.Pattern],
161
+ negate: bool = False,
162
+ ) -> bool:
135
163
  """Return True if name is in literals or matches any pattern."""
136
164
  res = name in name_lits or self._match_patts(name, name_patts)
137
165
  return not res if negate else res
138
166
 
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:
167
+ def _allow_block_predicate(
168
+ self,
169
+ name: str,
170
+ *,
171
+ allow_lits: set[str],
172
+ allow_patts: list[re.Pattern],
173
+ block_lits: set[str],
174
+ block_patts: list[re.Pattern],
175
+ ) -> bool:
142
176
  """Return True if name is allowed and not blocked (block takes precedence)."""
143
177
  if name in block_lits or self._match_patts(name, block_patts):
144
178
  return False
@@ -1,16 +1,18 @@
1
+ import io
1
2
  import os
2
3
  import re
3
4
 
4
5
  from collections.abc import Generator
6
+ from itertools import chain
5
7
  from pathlib import Path
6
- from typing_extensions import LiteralString, Callable, Any
8
+ from typing_extensions import Callable, Any
9
+ from typing import Iterable
7
10
 
8
11
  from .common import str2file, FilterSet, PredicateBuilder
9
12
 
10
13
 
11
14
  DEFAULT_SFX = '_filetree.txt'
12
15
 
13
- STYLES: list[str] = [' ', '-', '—', '_', '*', '>', '<', '+', '.']
14
16
 
15
17
  _NUM_SPLIT = re.compile(r'(\d+)').split
16
18
 
@@ -22,22 +24,47 @@ class FileTreeDisplay:
22
24
  visual representations of directories and files.
23
25
  Supports exclusion lists, configurable indentation, and custom prefix styles.
24
26
  """
27
+
28
+ __slots__ = (
29
+ 'root_path',
30
+ 'filepath',
31
+ 'ignore_dirs',
32
+ 'ignore_files',
33
+ 'include_dirs',
34
+ 'include_files',
35
+ 'style',
36
+ 'indent',
37
+ 'files_first',
38
+ 'skip_sorting',
39
+ 'sort_key_name',
40
+ 'reverse',
41
+ 'custom_sort',
42
+ 'save2file',
43
+ 'printout',
44
+ 'style_dict',
45
+ 'sort_keys',
46
+ 'pb',
47
+ 'dir_filter',
48
+ 'file_filter',
49
+ )
50
+
25
51
  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,
52
+ self,
53
+ root_dir: str | None = None,
54
+ filepath: str | None = None,
55
+ ignore_dirs: FilterSet = None,
56
+ ignore_files: FilterSet = None,
57
+ include_dirs: FilterSet = None,
58
+ include_files: FilterSet = None,
59
+ style: str = 'classic',
60
+ indent: int = 2,
61
+ files_first: bool = False,
62
+ skip_sorting: bool = False,
63
+ sort_key_name: str = 'natural',
64
+ reverse: bool = False,
65
+ custom_sort: Callable[[str], Any] | None = None,
66
+ save2file: bool = True,
67
+ printout: bool = False,
41
68
  ) -> None:
42
69
  """Initialize the FileTreeDisplay instance.
43
70
 
@@ -51,8 +78,8 @@ class FileTreeDisplay:
51
78
  style (str): Character(s) used to represent hierarchy levels. Defaults to " ".
52
79
  indent (int): Number of style characters used per hierarchy level. Defaults to 2.
53
80
  files_first (bool): Determines whether to list files first. Defaults to False.
81
+ skip_sorting (bool): Skip sorting directly, even if configured.
54
82
  sort_key_name (str): sorting key name, e.g. 'lex' for lexicographic or 'custom'. Defaults to 'natural'.
55
- '' means no sorting.
56
83
  reverse (bool): reversed sorting.
57
84
  custom_sort (Callable[[str], Any] | None):
58
85
  save2file (bool): save file tree info to a file.
@@ -67,69 +94,106 @@ class FileTreeDisplay:
67
94
  self.style = style
68
95
  self.indent = indent
69
96
  self.files_first = files_first
97
+ self.skip_sorting = skip_sorting
70
98
  self.sort_key_name = sort_key_name
71
99
  self.reverse = reverse
72
100
  self.custom_sort = custom_sort
73
101
  self.save2file = save2file
74
102
  self.printout = printout
75
103
 
104
+ self.style_dict: dict = {
105
+ 'classic': self.connector_styler('├── ', '└── '),
106
+ 'dash': self.connector_styler('|-- ', '`-- '),
107
+ 'arrow': self.connector_styler('├─> ', '└─> '),
108
+ 'plus': self.connector_styler('+--- ', '\\--- '),
109
+ }
110
+
76
111
  self.sort_keys = {
77
112
  'natural': self._nat_key,
78
113
  'lex': self._lex_key,
79
114
  'custom': self.custom_sort,
80
- '': None,
81
115
  }
82
116
 
83
117
  self.pb = PredicateBuilder()
84
118
  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)
119
+ self.file_filter = self.pb.build_predicate(
120
+ self.include_files, self.ignore_files
121
+ )
122
+
123
+ def format_style(self) -> dict:
124
+ style, style_dict = self.style, self.style_dict
125
+ style_keys = list(style_dict.keys())
126
+ if style not in style_keys:
127
+ raise ValueError(f"'{style}' is invalid: must be one of {style_keys}\n")
128
+ return style_dict[style]
86
129
 
87
130
  def init(self, *args, **kwargs) -> None:
88
131
  self.__init__(*args, **kwargs)
89
132
 
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):
133
+ def update_predicates(self) -> None:
97
134
  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)
135
+ self.file_filter = self.pb.build_predicate(
136
+ self.include_files, self.ignore_files
137
+ )
99
138
 
100
139
  @staticmethod
101
- def _nat_key(name: str) -> list[int | LiteralString]:
140
+ def _nat_key(name: str) -> list[int | str | Any]:
102
141
  """Natural sorting key"""
103
- return [int(part) if part.isdigit() else part.lower()
104
- for part in _NUM_SPLIT(name)]
142
+ return [
143
+ int(part) if part.isdigit() else part.lower() for part in _NUM_SPLIT(name)
144
+ ]
105
145
 
106
146
  @staticmethod
107
147
  def _lex_key(name: str) -> str:
108
148
  """Lexicographic sorting key"""
109
149
  return name.lower()
110
150
 
151
+ def _resolve_sort_key(self) -> Callable[[str], Any]:
152
+ sort_key_name, sort_keys = self.sort_key_name, self.sort_keys
153
+ key = sort_keys.get(sort_key_name)
154
+ if key is None:
155
+ if self.sort_key_name == 'custom':
156
+ raise ValueError(
157
+ "custom_sort function must be specified when sort_key_name='custom'"
158
+ )
159
+ raise ValueError(f'Invalid sort key name: "{sort_key_name}"! '
160
+ f'Currently defined keys are: {list(sort_keys.keys())}')
161
+ return key
162
+
111
163
  def file_tree_display(self) -> str:
112
- """Generate and save the directory tree to a text file.
164
+ """Generates a directory tree and saves it to a text file.
113
165
 
114
166
  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.
167
+ str: The complete directory tree as a single CRLF-delimited string.
117
168
  """
118
169
  root_path_str = str(self.root_path)
119
170
  filepath = self.filepath
171
+
120
172
  if not self.root_path.is_dir():
121
173
  raise NotADirectoryError(f"The path '{root_path_str}' is not a directory.")
122
174
 
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)
175
+ style = self.format_style()
176
+ sort_key = None if self.skip_sorting else self._resolve_sort_key()
177
+ dir_filter, file_filter = self.dir_filter, self.file_filter
178
+ files_first, reverse = self.files_first, self.reverse
179
+ indent = self.indent
180
+
181
+ iterator = self._build_tree(
182
+ root_path_str,
183
+ prefix='',
184
+ style=style,
185
+ sort_key=sort_key,
186
+ files_first=files_first,
187
+ dir_filter=dir_filter,
188
+ file_filter=file_filter,
189
+ reverse=reverse,
190
+ indent=indent,
191
+ )
127
192
 
128
193
  tree_info = self.get_tree_info(iterator)
129
194
 
130
- if self.save2file:
195
+ if self.save2file and filepath:
131
196
  str2file(tree_info, filepath)
132
- return filepath
133
197
 
134
198
  if self.printout:
135
199
  print(tree_info)
@@ -137,73 +201,110 @@ class FileTreeDisplay:
137
201
  return tree_info
138
202
 
139
203
  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.
204
+ buf = io.StringIO()
205
+ write = buf.write
206
+ write(f'{self.root_path.name}/\n')
207
+ buf.writelines(f'{line}\n' for line in iterator)
208
+ out = buf.getvalue()
209
+ return out[:-1] if out.endswith('\n') else out
210
+
211
+ def _build_tree(
212
+ self,
213
+ dir_path: str,
214
+ *,
215
+ prefix: str,
216
+ style: dict,
217
+ sort_key: Callable[[str], Any] | None,
218
+ files_first: bool,
219
+ dir_filter: Callable[[str], bool],
220
+ file_filter: Callable[[str], bool],
221
+ reverse: bool,
222
+ indent: int,
223
+ ) -> Generator[str, None, None]:
224
+ """Yields lines representing a formatted folder structure using a recursive DFS.
225
+ The internal recursive generator has runtime consts threaded through to avoid attrib. lookups.
148
226
 
149
227
  Args:
150
- dir_path (str): The directory path currently being traversed.
228
+ dir_path (str): The directory path or disk drive currently being traversed.
151
229
  prefix (str): Hierarchical prefix applied to each level.
152
230
 
153
231
  Yields:
154
- str: A formatted string representing either a directory or a file.
232
+ str: A formatted text representation of the folder structure.
155
233
  """
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
234
+ branch = style['branch']
235
+ end = style['end']
236
+ vertical = style['vertical']
237
+ space = style['space']
161
238
 
162
- next_prefix = prefix + curr_indent
239
+ recurse = self._build_tree
163
240
 
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}")
241
+ dirs: list[tuple[str, str]] = []
242
+ files: list[str] = []
169
243
 
170
244
  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:
245
+ with os.scandir(dir_path) as it:
246
+ append_dir = dirs.append
247
+ append_file = files.append
248
+ for entry in it:
175
249
  name = entry.name
176
- if entry.is_dir():
250
+ try:
251
+ is_dir = entry.is_dir(follow_symlinks=False)
252
+ except OSError:
253
+ continue
254
+
255
+ if is_dir:
177
256
  if dir_filter(name):
178
257
  append_dir((name, entry.path))
179
258
  else:
180
259
  if file_filter(name):
181
260
  append_file(name)
182
261
 
183
- except (PermissionError, OSError) as e:
184
- msg = '[Permission Denied]' if isinstance(e, PermissionError) else '[Error reading directory]'
185
- yield f'{next_prefix}{msg}'
262
+ except (PermissionError, OSError, FileNotFoundError) as e:
263
+ if isinstance(e, PermissionError):
264
+ yield '[Permission Denied]'
265
+ else:
266
+ yield '[Error reading directory]'
186
267
  return
187
268
 
188
269
  if sort_key:
189
270
  dirs.sort(key=lambda d: sort_key(d[0]), reverse=reverse)
190
271
  files.sort(key=sort_key, reverse=reverse)
191
272
 
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)
273
+ # Compute combined sequence without extra temporary tuples where possible
274
+ f_iter: Iterable[tuple[str, None, bool]] = ((f, None, False) for f in files)
275
+ d_iter: Iterable[tuple[str, str, bool]] = ((d[0], d[1], True) for d in dirs)
276
+ seq: Iterable[tuple[str, str | None, bool]] = (
277
+ chain(f_iter, d_iter) if files_first else chain(d_iter, f_iter)
208
278
  )
209
- return out_file
279
+
280
+ combined = list(seq)
281
+ last_index = len(combined) - 1
282
+
283
+ for idx, (name, path, is_dir) in enumerate(combined):
284
+ is_last = idx == last_index
285
+ connector = end if is_last else branch
286
+ formatted_name = f'{name}/' if is_dir else name
287
+ yield f'{prefix}{connector}{formatted_name}'
288
+ extension = space if is_last else vertical
289
+
290
+ if is_dir and path:
291
+ yield from recurse(
292
+ path,
293
+ prefix=prefix + extension,
294
+ style=style,
295
+ sort_key=sort_key,
296
+ files_first=files_first,
297
+ dir_filter=dir_filter,
298
+ file_filter=file_filter,
299
+ reverse=reverse,
300
+ indent=indent,
301
+ )
302
+
303
+ def connector_styler(self, branch: str, end: str) -> dict:
304
+ indent = self.indent
305
+ return {
306
+ 'space': ' ' * indent,
307
+ 'vertical': f'│{" " * (indent - 1)}',
308
+ 'branch': branch,
309
+ 'end': end,
310
+ }