treeing 1.0.1__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.
@@ -0,0 +1,567 @@
1
+ """
2
+ treeing/core/generator.py
3
+
4
+ Defines the logic for generating disk directories/files from the node tree.
5
+ Handles path registration, conflict detection, Windows reserved names and
6
+ over-long paths, strict-mode rollback, and nested-path expansion.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import re
13
+ import sys
14
+ import unicodedata
15
+ from collections.abc import Callable, Iterator
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+
19
+ from ..config import get_string
20
+ from .constants import TRANSPARENT_NODE_NAMES
21
+
22
+ # Windows reserved device names that cannot be used as filenames.
23
+ # MING lists COM1~COM9 and LPT1~LPT9 here because they have special meaning on Windows.
24
+ _WIN_RESERVED = {
25
+ 'CON', 'PRN', 'AUX', 'NUL',
26
+ *{f'COM{i}' for i in range(1, 10)},
27
+ *{f'LPT{i}' for i in range(1, 10)},
28
+ }
29
+
30
+ # Tree decoration characters (various box-drawing lines) replaced in filenames.
31
+ _TREE_DECOR_CHARS = re.compile(r'[\u2500-\u257F\u251C\u2514\u2502]')
32
+
33
+ # Windows path length limit.
34
+ _WIN_MAX_PATH = 260
35
+ _WIN_PATH_WARN_THRESHOLD = _WIN_MAX_PATH - 12
36
+
37
+ _CreatedEntry = tuple[str, Path]
38
+ _PathKind = str
39
+ _PathRegistry = dict[str, _PathKind]
40
+
41
+
42
+ def _kind_label(kind: _PathKind) -> str:
43
+ """
44
+ Turn 'dir'/'file' into a user-friendly label (directory/file).
45
+
46
+ Used by conflict error messages; falls back to file for unknown kinds.
47
+ """
48
+ if kind == 'dir':
49
+ return get_string('core_kind_dir')
50
+ return get_string('core_kind_file')
51
+
52
+
53
+ class PathConflictError(Exception):
54
+ """
55
+ Raised on a path conflict.
56
+
57
+ MING designed two modes:
58
+ - Normal mode: warn on conflict and continue (may overwrite or skip).
59
+ - Strict mode (--fail-on-conflict): raise immediately and roll back what
60
+ was already created.
61
+
62
+ The exception carries a `rolled` field telling the caller how many
63
+ files/directories were rolled back.
64
+ """
65
+
66
+ def __init__(self, name: str, rolled: int = 0):
67
+ self.name = name
68
+ self.rolled = rolled
69
+ if rolled > 0:
70
+ msg = get_string("core_err_path_conflict_rolled", name=name, rolled=rolled)
71
+ else:
72
+ msg = get_string("core_err_path_conflict", name=name)
73
+ super().__init__(msg)
74
+
75
+
76
+ DuplicateNameError = PathConflictError
77
+
78
+
79
+ @dataclass
80
+ class _CreateContext:
81
+ """
82
+ Generation context: records the output root, the registered paths and the list of created files.
83
+
84
+ The created list supports rollback: on a mid-run error, already-created
85
+ entries can be deleted in reverse order. MING made this a dataclass so the
86
+ recursive create_from_tree calls share one registry and one created list
87
+ instead of rebuilding state at every level.
88
+ """
89
+ output_root: Path
90
+ path_registry: _PathRegistry = field(default_factory=dict)
91
+ created: list[_CreatedEntry] | None = None
92
+
93
+ @classmethod
94
+ def begin(cls, output_root: Path, *, track_rollback: bool) -> _CreateContext:
95
+ """
96
+ Create a generation context.
97
+
98
+ When track_rollback is True, initialise the created list; subsequent
99
+ calls record every newly created directory and file for rollback on
100
+ error.
101
+ """
102
+ return cls(
103
+ output_root=output_root,
104
+ created=[] if track_rollback else None,
105
+ )
106
+
107
+
108
+ def sanitize_filename(name: str, flatten_slashes: bool = True) -> str:
109
+ """
110
+ Replace illegal characters in a filename with underscores, preserving intent where possible.
111
+
112
+ Some Windows reserved names (CON, PRN, COM1, ...) cannot be used as
113
+ filenames directly; this function prefixes them with an underscore. Tree
114
+ symbols and control characters are cleaned up too.
115
+
116
+ MING added this to catch users who write emoji or CJK punctuation as
117
+ filenames in hand-written trees.
118
+
119
+ With flatten_slashes=False, a path like foo/bar is split into segments,
120
+ each sanitised, then rejoined (used with --allow-nested-names).
121
+ """
122
+ name = unicodedata.normalize('NFC', name)
123
+ if not flatten_slashes:
124
+ name = name.replace('\\', '/')
125
+ parts = name.split('/')
126
+ cleaned = [sanitize_filename(part, flatten_slashes=True) for part in parts if part]
127
+ return '/'.join(cleaned) if cleaned else '_'
128
+ illegal_chars = r'[\\/:*?"<>|\t]'
129
+ safe = re.sub(illegal_chars, '_', name)
130
+ safe = _TREE_DECOR_CHARS.sub('_', safe)
131
+ safe = re.sub(r'[\x00-\x1f]', '_', safe)
132
+ safe = safe.strip()
133
+ if sys.platform.startswith('win'):
134
+ safe = safe.rstrip('. ')
135
+ if not safe:
136
+ safe = '_'
137
+ stem = safe.split('.')[0].upper()
138
+ if stem in _WIN_RESERVED:
139
+ safe = f'_{safe}'
140
+ return safe
141
+
142
+
143
+ def parse_nested_name(name: str) -> list[str] | None:
144
+ """
145
+ Parse a nested name (e.g. foo/bar/baz) and return its path segments.
146
+
147
+ Returns None if the name is an absolute path (starts with / or a Windows
148
+ drive letter) or contains .., meaning it is unsafe to expand.
149
+ """
150
+ normalized = name.replace('\\', '/')
151
+ if normalized.startswith('/'):
152
+ return None
153
+ if len(normalized) >= 2 and normalized[1] == ':':
154
+ return None
155
+ parts = [p for p in normalized.split('/') if p and p != '.']
156
+ if not parts or '..' in parts:
157
+ return None
158
+ return parts
159
+
160
+
161
+ def _path_str(path: Path) -> str:
162
+ """
163
+ Convert a Path to a string, preferring resolve() and falling back to absolute().
164
+
165
+ MING wraps this in try/except because over-long or unusual Windows paths
166
+ may raise OSError on resolve(); absolute() at least lets processing
167
+ continue.
168
+ """
169
+ try:
170
+ return str(path.resolve())
171
+ except OSError:
172
+ return str(path.absolute())
173
+
174
+
175
+ def _rel_path_key(full_path: Path, output_root: Path) -> str:
176
+ """
177
+ Compute a path's path relative to the output root, used as the registry key.
178
+
179
+ If the path is not under the output root (which should not happen), fall
180
+ back to the full path so no registry entry is lost.
181
+ """
182
+ try:
183
+ return full_path.relative_to(output_root).as_posix()
184
+ except ValueError:
185
+ return full_path.as_posix()
186
+
187
+
188
+ def _register_path(
189
+ rel_key: str,
190
+ kind: _PathKind,
191
+ registry: _PathRegistry,
192
+ emit: Callable[[str], None],
193
+ fail_on_conflict: bool,
194
+ *,
195
+ dry_run: bool = False,
196
+ ) -> bool:
197
+ """
198
+ Register a path in the registry and check for conflicts.
199
+
200
+ Conflicts include:
201
+ - Same name but different type (file vs directory).
202
+ - Creating a child path under a file.
203
+ - Creating a same-named file under a directory.
204
+
205
+ On conflict with fail_on_conflict set, raise PathConflictError; otherwise
206
+ warn and return False to indicate the node should be skipped.
207
+ """
208
+ if not rel_key:
209
+ return True
210
+
211
+ def abort() -> None:
212
+ """Raise PathConflictError in strict mode; do not raise in dry-run."""
213
+ if fail_on_conflict and not dry_run:
214
+ raise PathConflictError(rel_key)
215
+
216
+ if rel_key in registry:
217
+ existing = registry[rel_key]
218
+ if existing == kind:
219
+ emit(get_string("core_warn_duplicate_path", name=rel_key))
220
+ abort()
221
+ return not fail_on_conflict
222
+ emit(get_string(
223
+ "core_warn_path_type_conflict",
224
+ path=rel_key,
225
+ existing_kind=_kind_label(existing),
226
+ new_kind=_kind_label(kind),
227
+ ))
228
+ abort()
229
+ return False
230
+
231
+ prefix = rel_key + '/'
232
+ for other, other_kind in registry.items():
233
+ if other_kind == 'file' and rel_key.startswith(other + '/'):
234
+ emit(get_string("core_warn_path_under_file", path=rel_key, file=other))
235
+ abort()
236
+ return False
237
+ if kind == 'file' and other.startswith(prefix):
238
+ emit(get_string("core_warn_path_under_file", path=other, file=rel_key))
239
+ abort()
240
+ return False
241
+
242
+ registry[rel_key] = kind
243
+ return True
244
+
245
+
246
+ def _existing_disk_kind(path: Path) -> _PathKind | None:
247
+ """
248
+ Check what kind of entry already exists on disk at a path.
249
+
250
+ Returns 'file', 'dir' or None (does not exist or access failed).
251
+ """
252
+ prepared = _prepare_windows_path(path)
253
+ try:
254
+ if prepared.is_dir():
255
+ return 'dir'
256
+ if prepared.is_file():
257
+ return 'file'
258
+ except OSError:
259
+ pass
260
+ return None
261
+
262
+
263
+ def _check_disk_conflict(
264
+ path: Path,
265
+ expected_kind: _PathKind,
266
+ rel_key: str,
267
+ emit: Callable[[str], None],
268
+ fail_on_conflict: bool,
269
+ *,
270
+ dry_run: bool = False,
271
+ ) -> bool:
272
+ """
273
+ Check whether the existing on-disk type conflicts with the type we want to create.
274
+
275
+ If a different-typed entry already exists on disk (e.g. a file where we
276
+ want a directory), warn. With fail_on_conflict set, raise.
277
+ """
278
+ existing = _existing_disk_kind(path)
279
+ if existing is None or existing == expected_kind:
280
+ return True
281
+ emit(get_string(
282
+ "core_warn_disk_type_conflict",
283
+ path=rel_key,
284
+ existing_kind=_kind_label(existing),
285
+ new_kind=_kind_label(expected_kind),
286
+ ))
287
+ if fail_on_conflict and not dry_run:
288
+ raise PathConflictError(rel_key)
289
+ return False
290
+
291
+
292
+ def _warn_if_path_long(path: Path, emit: Callable[[str], None]) -> None:
293
+ """
294
+ On Windows, warn when a path approaches the 260-character limit.
295
+
296
+ The threshold is 260 - 12 = 248, a buffer MING reserved: some tools append
297
+ extensions or escape characters later, so an early warning is friendlier
298
+ than an error at the actual limit.
299
+ """
300
+ if not sys.platform.startswith('win'):
301
+ return
302
+ text = _path_str(path)
303
+ if len(text) >= _WIN_PATH_WARN_THRESHOLD:
304
+ emit(get_string("core_warn_path_too_long", path=text, length=len(text)))
305
+
306
+
307
+ def _prepare_windows_path(path: Path) -> Path:
308
+ """
309
+ Handle over-long Windows paths.
310
+
311
+ If the path length is >= 260 and it has no \\\\?\\ prefix, add one.
312
+ UNC paths use the \\\\?\\UNC\\ form.
313
+ """
314
+ if not sys.platform.startswith('win'):
315
+ return path
316
+ text = _path_str(path)
317
+ if len(text) < _WIN_MAX_PATH or text.startswith('\\\\?\\'):
318
+ return path
319
+ if text.startswith('\\\\'):
320
+ return Path('\\\\?\\UNC\\' + text[2:])
321
+ return Path('\\\\?\\' + os.path.abspath(text))
322
+
323
+
324
+ def _track_new_dirs(path: Path, created: list[_CreatedEntry] | None) -> None:
325
+ """
326
+ Record the parent directories that did not exist before creating a directory, for rollback.
327
+
328
+ Walks upward from the target until it hits an existing directory or the
329
+ root, then appends the new chain to `created` from shallow to deep.
330
+ """
331
+ if created is None:
332
+ return
333
+ prepared = _prepare_windows_path(path)
334
+ new_dirs: list[Path] = []
335
+ p = prepared
336
+ while not p.exists() and p != p.parent:
337
+ new_dirs.append(p)
338
+ p = p.parent
339
+ for p in reversed(new_dirs):
340
+ created.append(('dir', p))
341
+
342
+
343
+ def _mkdir(path: Path, dry_run: bool, created: list[_CreatedEntry] | None = None) -> Path:
344
+ """
345
+ Create a directory (supports dry-run and rollback tracking).
346
+
347
+ In dry-run mode, returns the prepared path without touching disk. In real
348
+ mode, records the newly created parents first so rollback can delete them
349
+ in reverse order.
350
+ """
351
+ prepared = _prepare_windows_path(path)
352
+ if not dry_run:
353
+ if not prepared.exists():
354
+ _track_new_dirs(prepared, created)
355
+ prepared.mkdir(parents=True, exist_ok=True)
356
+ return prepared
357
+
358
+
359
+ def _touch(path: Path, dry_run: bool, created: list[_CreatedEntry] | None = None) -> Path:
360
+ """
361
+ Create an empty file (supports dry-run and rollback tracking).
362
+
363
+ In real mode, ensures the parent directory exists, then records the file
364
+ in the created list. Note: an already-existing file is not appended, so
365
+ rollback does not delete pre-existing files.
366
+ """
367
+ prepared = _prepare_windows_path(path)
368
+ if not dry_run:
369
+ _mkdir(prepared.parent, dry_run, created)
370
+ existed = prepared.exists()
371
+ prepared.touch(exist_ok=True)
372
+ if created is not None and not existed:
373
+ created.append(('file', prepared))
374
+ return prepared
375
+
376
+
377
+ def _rollback_created(created: list[_CreatedEntry]) -> int:
378
+ """
379
+ Roll back created files and directories.
380
+
381
+ Deletes in reverse order and returns the number actually removed. Failed
382
+ deletions are ignored (the rollback may already be partial).
383
+ """
384
+ rolled = 0
385
+ for kind, path in reversed(created):
386
+ try:
387
+ prepared = _prepare_windows_path(path)
388
+ if kind == 'file' and prepared.is_file():
389
+ prepared.unlink(missing_ok=True)
390
+ rolled += 1
391
+ elif kind == 'dir' and prepared.is_dir():
392
+ prepared.rmdir()
393
+ rolled += 1
394
+ except OSError:
395
+ pass
396
+ return rolled
397
+
398
+
399
+ def create_from_tree(
400
+ tree: list[dict],
401
+ root_path: Path,
402
+ dry_run: bool = False,
403
+ warnings: list[str] | None = None,
404
+ allow_nested_names: bool = False,
405
+ fail_on_conflict: bool = False,
406
+ fail_on_duplicate: bool | None = None,
407
+ rollback_on_error: bool = False,
408
+ _ctx: _CreateContext | None = None,
409
+ ) -> None:
410
+ """
411
+ Create directories and files on disk from the parsed tree.
412
+
413
+ This is the core function of the generator module. It is fairly complex
414
+ and does the following:
415
+
416
+ 1. In strict or rollback mode, set up a context with a created list so a
417
+ later error can roll things back.
418
+ 2. Walk each node, skipping virtual nodes (they are not created).
419
+ 3. Parse nested path names (when allow_nested_names is on).
420
+ 4. Sanitise illegal characters out of filenames.
421
+ 5. Register the path and check for conflicts.
422
+ 6. Check the disk for a same-named entry of a different type.
423
+ 7. Create the directory or file.
424
+ 8. Recurse into children.
425
+
426
+ MING deliberately put the rollback logic at the outermost layer so that no
427
+ matter which step fails, the scene can be cleaned up and no half-finished
428
+ tree is left behind.
429
+ """
430
+ strict = fail_on_conflict or bool(fail_on_duplicate)
431
+
432
+ if _ctx is None:
433
+ track_rollback = not dry_run and (strict or rollback_on_error)
434
+ if track_rollback:
435
+ ctx = _CreateContext.begin(root_path, track_rollback=True)
436
+ try:
437
+ create_from_tree(
438
+ tree, root_path, dry_run, warnings, allow_nested_names,
439
+ fail_on_conflict=strict, rollback_on_error=False, _ctx=ctx,
440
+ )
441
+ except PathConflictError as e:
442
+ rolled = _rollback_created(ctx.created or [])
443
+ raise PathConflictError(e.name, rolled=rolled) from e
444
+ except Exception:
445
+ _rollback_created(ctx.created or [])
446
+ raise
447
+ return
448
+ _ctx = _CreateContext.begin(root_path, track_rollback=False)
449
+
450
+ output_root = _ctx.output_root
451
+ path_registry = _ctx.path_registry
452
+ created = _ctx.created
453
+
454
+ def _emit(message: str) -> None:
455
+ """Append a message to the warnings list, or print to stdout when there is no warnings list."""
456
+ if warnings is not None:
457
+ warnings.append(message)
458
+ else:
459
+ print(message)
460
+
461
+ def _register_dir_path(full_path: Path) -> bool:
462
+ """Register a directory path in the registry; return whether creation may continue."""
463
+ rel_key = _rel_path_key(full_path, output_root)
464
+ return _register_path(
465
+ rel_key, 'dir', path_registry, _emit, strict, dry_run=dry_run,
466
+ )
467
+
468
+ def _ensure_disk_compatible(full_path: Path, kind: _PathKind) -> bool:
469
+ """Check whether the on-disk type is compatible with the type to create; return False if not."""
470
+ rel_key = _rel_path_key(full_path, output_root)
471
+ return _check_disk_conflict(
472
+ full_path, kind, rel_key, _emit, strict, dry_run=dry_run,
473
+ )
474
+
475
+ def _resolve_target(node: dict) -> tuple[Path, str, bool, bool]:
476
+ """
477
+ Decide the parent directory and final name for a node.
478
+
479
+ When allow_nested_names is on and the name contains a slash, split the
480
+ path, create each intermediate parent, and return the deepest parent
481
+ plus the final segment.
482
+
483
+ Returns skip_node=True when the name is illegal (absolute path or
484
+ contains ..).
485
+ """
486
+ raw_name = node['name']
487
+ if allow_nested_names and ('/' in raw_name or '\\' in raw_name):
488
+ nested_parts = parse_nested_name(raw_name)
489
+ if nested_parts is None:
490
+ _emit(get_string("core_warn_nested_rejected", name=raw_name))
491
+ else:
492
+ parent = root_path
493
+ for part in nested_parts[:-1]:
494
+ segment = sanitize_filename(part)
495
+ parent = parent / segment
496
+ _warn_if_path_long(parent, _emit)
497
+ if not _register_dir_path(parent):
498
+ return parent, '', False, True
499
+ if not _ensure_disk_compatible(parent, 'dir'):
500
+ return parent, '', False, True
501
+ _mkdir(parent, dry_run, created)
502
+ final = sanitize_filename(nested_parts[-1])
503
+ return parent, final, True, False
504
+
505
+ final = sanitize_filename(raw_name)
506
+ return root_path, final, False, False
507
+
508
+ def _recurse(children: list[dict], new_root: Path) -> None:
509
+ """Recurse into children, passing new_root as the new output root to create_from_tree."""
510
+ create_from_tree(
511
+ children, new_root, dry_run, warnings, allow_nested_names,
512
+ fail_on_conflict=strict, _ctx=_ctx,
513
+ )
514
+
515
+ for node in tree:
516
+ if node['name'] in TRANSPARENT_NODE_NAMES:
517
+ _recurse(node.get('children', []), root_path)
518
+ continue
519
+
520
+ parent_path, final_name, used_nested, skip_node = _resolve_target(node)
521
+ if skip_node:
522
+ _emit(get_string("core_warn_skip_subtree", name=node['name']))
523
+ continue
524
+
525
+ full_path = parent_path / final_name
526
+ rel_key = _rel_path_key(full_path, output_root)
527
+ node_kind: _PathKind = 'dir' if node.get('is_dir', False) else 'file'
528
+ if not _register_path(
529
+ rel_key, node_kind, path_registry, _emit, strict, dry_run=dry_run,
530
+ ):
531
+ if node.get('children'):
532
+ _emit(get_string("core_warn_skip_subtree", name=node['name']))
533
+ continue
534
+
535
+ if not used_nested and final_name != node['name']:
536
+ _emit(get_string("core_warn_name_clean", old=node['name'], new=final_name))
537
+
538
+ _warn_if_path_long(full_path, _emit)
539
+
540
+ if node.get('is_dir', False):
541
+ if not _ensure_disk_compatible(full_path, 'dir'):
542
+ if node.get('children'):
543
+ _emit(get_string("core_warn_skip_subtree", name=node['name']))
544
+ continue
545
+ _mkdir(full_path, dry_run, created)
546
+ _recurse(node.get('children', []), full_path)
547
+ else:
548
+ if not _ensure_disk_compatible(full_path, 'file'):
549
+ if node.get('children'):
550
+ _emit(get_string("core_warn_skip_subtree", name=node['name']))
551
+ continue
552
+ _touch(full_path, dry_run, created)
553
+ if node.get('children'):
554
+ _recurse(node.get('children', []), full_path)
555
+
556
+
557
+ def iter_nodes(tree: list[dict]) -> Iterator[dict]:
558
+ """
559
+ Iterate every node in the tree, skipping virtual nodes (<auto>, <virtual>, .).
560
+
561
+ Used to count "nodes actually to be created", or for any traversal that
562
+ needs to ignore virtual nodes.
563
+ """
564
+ for node in tree:
565
+ if node['name'] not in TRANSPARENT_NODE_NAMES:
566
+ yield node
567
+ yield from iter_nodes(node.get('children', []))