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.
- treeing/__init__.py +7 -0
- treeing/_release_defaults.py +3 -0
- treeing/assets/icon.icns +0 -0
- treeing/assets/icon.ico +0 -0
- treeing/assets/icon.png +0 -0
- treeing/cli/__init__.py +5 -0
- treeing/cli/confirm.py +197 -0
- treeing/cli/help_text.py +311 -0
- treeing/cli/io.py +72 -0
- treeing/cli/main.py +427 -0
- treeing/cli/report.py +104 -0
- treeing/cli_entry.py +16 -0
- treeing/config.py +205 -0
- treeing/core/__init__.py +15 -0
- treeing/core/constants.py +20 -0
- treeing/core/generator.py +567 -0
- treeing/core/parser.py +407 -0
- treeing/core/preview.py +98 -0
- treeing/gui/__init__.py +5 -0
- treeing/gui/app.py +886 -0
- treeing/gui/dnd.py +124 -0
- treeing/gui/icon.py +69 -0
- treeing/gui/preview.py +9 -0
- treeing/gui/settings.py +80 -0
- treeing/gui/tooltip.py +234 -0
- treeing/gui_entry.py +39 -0
- treeing/main.py +21 -0
- treeing/path_checks.py +87 -0
- treeing/strings.bootstrap.json +7 -0
- treeing/strings.json +222 -0
- treeing-1.0.1.dist-info/METADATA +112 -0
- treeing-1.0.1.dist-info/RECORD +36 -0
- treeing-1.0.1.dist-info/WHEEL +5 -0
- treeing-1.0.1.dist-info/entry_points.txt +3 -0
- treeing-1.0.1.dist-info/licenses/LICENSE +21 -0
- treeing-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -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', []))
|