@codebend3r/che 1.1.0 → 1.2.1

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.
Files changed (54) hide show
  1. package/README.md +58 -26
  2. package/bin/art.py +22 -20
  3. package/bin/branchsync.py +324 -0
  4. package/bin/che.py +208 -587
  5. package/bin/commands.py +219 -231
  6. package/bin/doctor.py +220 -0
  7. package/bin/drives/eject-all-drives.py +21 -38
  8. package/bin/drives/mount-all-drives.py +18 -62
  9. package/bin/drives/ping-nas.py +77 -105
  10. package/bin/files/compress-folders.py +7 -11
  11. package/bin/files/delete-by-ext.py +10 -14
  12. package/bin/files/delete-empty-folders.py +33 -37
  13. package/bin/files/delete-smb-files.py +12 -21
  14. package/bin/files/files-under-size.py +5 -11
  15. package/bin/files/find-largest-files.py +21 -25
  16. package/bin/files/list-permission.py +40 -0
  17. package/bin/files/make-alpha-dir.py +2 -0
  18. package/bin/git/all-actions.py +350 -253
  19. package/bin/git/checkout-my-branches.py +42 -63
  20. package/bin/git/clean-stale-branches.py +23 -48
  21. package/bin/git/prune-worktrees.py +12 -51
  22. package/bin/git/sync-all-branches.py +75 -297
  23. package/bin/git/update-local-branches.py +67 -73
  24. package/bin/gitutils.py +263 -0
  25. package/bin/install.py +393 -1182
  26. package/bin/machine.py +345 -0
  27. package/bin/media.py +127 -0
  28. package/bin/menu.py +437 -0
  29. package/bin/nas.py +102 -0
  30. package/bin/rcfile.py +380 -0
  31. package/bin/shellgen.py +503 -431
  32. package/bin/system/btop-launch.py +17 -13
  33. package/bin/system/update-brew.py +57 -68
  34. package/bin/tui.py +156 -153
  35. package/bin/utils.py +165 -237
  36. package/bin/video/delete-duplicate-videos.py +64 -62
  37. package/bin/video/detect-green-magenta-videos.py +105 -95
  38. package/bin/video/find-movie-by-year.py +3 -0
  39. package/bin/video/find-video-mkv-issues.py +48 -65
  40. package/bin/video/fix-codecs.py +25 -30
  41. package/bin/video/largest-tv-shows.py +60 -57
  42. package/bin/video/remove-metadata.py +3 -6
  43. package/bin/video/rename-video-file.py +74 -84
  44. package/bin/video/scan-videos-audio-language.py +16 -33
  45. package/bin/video/show-codecs.py +20 -24
  46. package/bin/video/validate-video-files.py +5 -8
  47. package/bin/video/video-list.py +17 -20
  48. package/package.json +4 -10
  49. package/shell/che.bash +33 -33
  50. package/shell/che.fish +7 -7
  51. package/shell/che.zsh +33 -33
  52. package/shell/completions/_che +19 -19
  53. package/shell/completions/che.bash +15 -15
  54. package/shell/completions/che.fish +34 -50
package/bin/utils.py CHANGED
@@ -13,6 +13,10 @@ Everything here is standard library only, on purpose: the scripts have to run
13
13
  from a bare ``python3`` on any machine this repo is cloned to, with no venv and
14
14
  no install step.
15
15
 
16
+ Domain helpers live beside it: git and worktree helpers in ``gitutils.py``,
17
+ ffprobe helpers in ``media.py``, the NAS drive table and SMB mounting in
18
+ ``nas.py``.
19
+
16
20
  =============================================================================
17
21
  CLI conventions - shared patterns for bin/<category>/*.py
18
22
 
@@ -30,12 +34,12 @@ CLI conventions - shared patterns for bin/<category>/*.py
30
34
  from __future__ import annotations
31
35
 
32
36
  import argparse
33
- import json
34
37
  import os
35
38
  import re
36
39
  import shutil
37
40
  import subprocess
38
41
  import sys
42
+ import tempfile
39
43
  from collections.abc import Callable, Iterable, Iterator, Sequence
40
44
  from fnmatch import fnmatchcase
41
45
  from pathlib import Path
@@ -52,12 +56,13 @@ YELLOW = "\033[1;33m"
52
56
  CYAN = "\033[1;36m"
53
57
  MAGENTA = "\033[1;35m"
54
58
 
55
- _FORCE_COLOR_OFF = {"", "0", "false", "no", "off"}
56
-
57
59
 
58
- def _color_enabled() -> bool:
60
+ def color_enabled() -> bool:
59
61
  """Honor NO_COLOR and non-tty output the way a well-behaved CLI should.
60
62
 
63
+ The log helpers and :func:`paint` ask this, and so must any caller that
64
+ builds its own escape sequences (per-language colouring, table headers).
65
+
61
66
  DELIBERATE DIVERGENCE from ``utils.sh``, which always emitted escapes.
62
67
  Suppressing them when stdout is not a terminal keeps captured output clean
63
68
  for the tests and for shell pipelines. ``FORCE_COLOR`` overrides, following
@@ -66,8 +71,8 @@ def _color_enabled() -> bool:
66
71
  The tty check happens at call time, not import time, so
67
72
  ``contextlib.redirect_stdout`` and pytest's capture are both respected.
68
73
  """
69
- force = os.environ.get("FORCE_COLOR")
70
- if force is not None and force.lower() not in _FORCE_COLOR_OFF:
74
+ force = os.environ.get("FORCE_COLOR", "").strip().lower()
75
+ if force and force not in _FALSE_VALUES:
71
76
  return True
72
77
  if os.environ.get("NO_COLOR"):
73
78
  return False
@@ -77,11 +82,14 @@ def _color_enabled() -> bool:
77
82
  return False
78
83
 
79
84
 
80
- def color_enabled() -> bool:
81
- """Public form of :func:`_color_enabled`, for callers that build their own
82
- escape sequences (per-language colouring, table headers) and must honour
83
- ``NO_COLOR`` the same way the log helpers do."""
84
- return _color_enabled()
85
+ def paint(text: str, color: str) -> str:
86
+ """``text`` wrapped in ``color``, or bare when colour is off.
87
+
88
+ For output assembled before it is printed - a table cell, one coloured word
89
+ in a plain line. An ``f"{RED}...{NC}"`` built by hand ignores ``NO_COLOR``
90
+ and leaks escapes into piped output.
91
+ """
92
+ return f"{color}{text}{NC}" if color_enabled() else text
85
93
 
86
94
 
87
95
  def log_color(color: str, message: str) -> None:
@@ -97,10 +105,7 @@ def log_color(color: str, message: str) -> None:
97
105
  error messages appear *before* the banner that introduced them whenever the
98
106
  output is piped to a file or a CI log.
99
107
  """
100
- if _color_enabled():
101
- print(f"{color}{message}{NC}", flush=True)
102
- else:
103
- print(message, flush=True)
108
+ print(paint(message, color), flush=True)
104
109
 
105
110
 
106
111
  def log(message: str) -> None:
@@ -128,6 +133,49 @@ def success(message: str) -> None:
128
133
  log_color(MAGENTA, message)
129
134
 
130
135
 
136
+ # ---------------------------------------------------------------------------
137
+ # Banners
138
+ #
139
+ # The fixed shapes scripts frame their output with, so the widths and the
140
+ # dry-run marker stay identical from one script to the next.
141
+ # ---------------------------------------------------------------------------
142
+
143
+ _HEADING_RULE = "═" * 43
144
+ _SETTINGS_RULE = "-" * 52
145
+ _FOOTER_RULE = "─" * 45
146
+
147
+
148
+ def heading(title: str, *, dry_run: bool = False) -> None:
149
+ """The three-line banner a script opens with, marked when it only previews."""
150
+ note(_HEADING_RULE)
151
+ note(f"{title}{' 🌵 (dry run)' if dry_run else ''}")
152
+ note(_HEADING_RULE)
153
+
154
+
155
+ def note_settings(*rows: tuple[str, object], rule: bool = True) -> None:
156
+ """Echo a script's resolved settings as ``Label: value`` lines.
157
+
158
+ Booleans print lowercase, ``true``/``false``, as the shell versions echoed
159
+ them; anything else prints as-is. ``rule`` closes the block with the dashed
160
+ separator that sets the settings apart from the results.
161
+ """
162
+ for label, value in rows:
163
+ note(f"{label}: {str(value).lower() if isinstance(value, bool) else value}")
164
+ if rule:
165
+ note(_SETTINGS_RULE)
166
+
167
+
168
+ def footer_rule() -> None:
169
+ """A blank line and a thin rule, opening a script's closing tally."""
170
+ print()
171
+ note(_FOOTER_RULE)
172
+
173
+
174
+ def dry_run_complete(verb: str = "changed") -> None:
175
+ """The closing line of a dry run: ``Nothing was actually <verb>.``"""
176
+ log(f"💡 Dry run complete. Nothing was actually {verb}.")
177
+
178
+
131
179
  # ---------------------------------------------------------------------------
132
180
  # Formatting
133
181
  # ---------------------------------------------------------------------------
@@ -183,7 +231,7 @@ _FALSE_VALUES = {"false", "no", "0", "off"}
183
231
  _FALSE_SUFFIX = "-set-false-internal"
184
232
 
185
233
 
186
- def parse_bool(value: str | bool) -> bool:
234
+ def parse_bool(value: str) -> bool:
187
235
  """Parse a boolean spelling.
188
236
 
189
237
  Accepts ``true|yes|1|on`` and ``false|no|0|off``, case-insensitive, and
@@ -195,9 +243,6 @@ def parse_bool(value: str | bool) -> bool:
195
243
  There ``true``/``false`` worked, ``1``/``0``/``on``/``off``/``no`` were
196
244
  "command not found", and ``yes`` invoked ``/usr/bin/yes`` and hung forever.
197
245
  """
198
- if isinstance(value, bool):
199
- return value
200
-
201
246
  lowered = value.strip().lower()
202
247
  if lowered in _TRUE_VALUES:
203
248
  return True
@@ -250,10 +295,8 @@ class BoolFlagParser(argparse.ArgumentParser):
250
295
  rewritten.append(flag if enabled else self._bool_flags[flag])
251
296
  return rewritten
252
297
 
253
- def parse_args(self, args=None, namespace=None): # type: ignore[override]
254
- argv = list(sys.argv[1:] if args is None else args)
255
- return super().parse_args(self._rewrite(argv), namespace)
256
-
298
+ # parse_args delegates to parse_known_args, so this one override rewrites
299
+ # argv for both - and only once.
257
300
  def parse_known_args(self, args=None, namespace=None): # type: ignore[override]
258
301
  argv = list(sys.argv[1:] if args is None else args)
259
302
  return super().parse_known_args(self._rewrite(argv), namespace)
@@ -343,6 +386,28 @@ def env_bool(name: str, default: bool) -> bool:
343
386
  sys.exit(1)
344
387
 
345
388
 
389
+ _COUNT_RE = re.compile(r"[0-9]+")
390
+
391
+
392
+ def parse_count(raw: str, flag: str, *, minimum: int = 0) -> int:
393
+ """Validate an integer flag, or report why it is invalid and exit 1.
394
+
395
+ Integer flags are declared as plain strings and validated here, not with
396
+ ``type=int``. argparse's own conversion would own the failure and exit
397
+ before the shell version's message could print, and ``int()`` accepts
398
+ ``+5``, ``5_0`` and surrounding whitespace, all of which the shell's
399
+ ``^[0-9]+$`` test rejected. A leading zero is decimal: ``010`` is 10.
400
+
401
+ ``minimum`` of 1 or more reads "positive integer" in the message, 0 reads
402
+ "non-negative integer".
403
+ """
404
+ if not _COUNT_RE.fullmatch(raw) or int(raw) < minimum:
405
+ kind = "positive" if minimum > 0 else "non-negative"
406
+ warning(f"❌ {flag} must be a {kind} integer (got: {raw})")
407
+ sys.exit(1)
408
+ return int(raw)
409
+
410
+
346
411
  # ---------------------------------------------------------------------------
347
412
  # Subprocess
348
413
  # ---------------------------------------------------------------------------
@@ -375,7 +440,6 @@ def run(
375
440
  check: bool = True,
376
441
  capture: bool = False,
377
442
  cwd: str | Path | None = None,
378
- text: bool = True,
379
443
  env: dict[str, str] | None = None,
380
444
  stdin: int | None = None,
381
445
  timeout: float | None = None,
@@ -398,12 +462,12 @@ def run(
398
462
  check=check,
399
463
  capture_output=capture,
400
464
  cwd=cwd,
401
- text=text,
465
+ text=True,
402
466
  # errors="replace" rather than the default "strict": a single
403
467
  # non-UTF-8 byte in a child's diagnostic (a legacy-encoded title tag
404
468
  # echoed back by ffmpeg, say) would otherwise raise UnicodeDecodeError
405
469
  # and abort a whole library scan partway through.
406
- errors="replace" if text else None,
470
+ errors="replace",
407
471
  env=merged_env,
408
472
  stdin=stdin,
409
473
  timeout=timeout,
@@ -426,20 +490,6 @@ def run_output(argv: Sequence[str], *, check: bool = True, cwd: str | Path | Non
426
490
  return (completed.stdout or "").rstrip("\n")
427
491
 
428
492
 
429
- def run_lines(
430
- argv: Sequence[str], *, check: bool = True, cwd: str | Path | None = None
431
- ) -> list[str]:
432
- """Run a command and return stdout split into non-empty lines.
433
-
434
- DELIBERATE DIVERGENCE: in the shell scripts these reads sat inside
435
- ``< <(...)`` process substitution, whose failure does NOT trip ``set -e`` -
436
- a failing ``git for-each-ref`` just looked like "no branches" and the script
437
- exited 0. Here a failure raises.
438
- """
439
- output = run_output(argv, check=check, cwd=cwd)
440
- return [line for line in output.split("\n") if line]
441
-
442
-
443
493
  def run_cli(entry: Callable[[], int]) -> None:
444
494
  """Run a script's ``main`` and translate exceptions into shell exit codes.
445
495
 
@@ -469,202 +519,20 @@ def run_cli(entry: Callable[[], int]) -> None:
469
519
  sys.exit(130)
470
520
 
471
521
 
472
- # ---------------------------------------------------------------------------
473
- # Worktrees
474
- #
475
- # A branch checked out in a linked worktree cannot be checked out, deleted or
476
- # reset from the main clone - git refuses with "already used by worktree".
477
- # That is a mechanical limit, not a conflict, so every script that touches
478
- # branches needs to know which ones are pinned and where.
479
- # ---------------------------------------------------------------------------
480
-
481
-
482
- def worktree_branch_map(cwd: str | Path | None = None) -> dict[str, str]:
483
- """Map ``branch -> worktree path`` for every worktree with a branch checked out.
484
-
485
- Detached worktrees are skipped (they pin no branch). The main worktree IS
486
- included, because its branch is pinned for exactly the same reason.
487
-
488
- DELIBERATE DIVERGENCE: ``utils.sh`` emitted "<branch>\t<path>" lines that
489
- every caller re-parsed. A dict is the same data without the round trip, and
490
- it cannot be truncated by a path containing a tab.
491
- """
492
- mapping: dict[str, str] = {}
493
- path = ""
494
- branch = ""
495
-
496
- def flush() -> None:
497
- if path and branch:
498
- # First writer wins: a branch can only be pinned by one worktree,
499
- # so a duplicate would mean git contradicted itself.
500
- mapping.setdefault(branch, path)
501
-
502
- for line in run_output(["git", "worktree", "list", "--porcelain"], cwd=cwd).splitlines():
503
- if line.startswith("worktree "):
504
- # A new record starts; flush the previous one.
505
- flush()
506
- path = line.removeprefix("worktree ")
507
- branch = ""
508
- elif line.startswith("branch refs/heads/"):
509
- branch = line.removeprefix("branch refs/heads/")
510
-
511
- flush()
512
- return mapping
513
-
514
-
515
- def worktree_for_branch(branch: str, cwd: str | Path | None = None) -> str:
516
- """The worktree path holding ``branch``, or "" when no worktree does."""
517
- return worktree_branch_map(cwd).get(branch, "")
518
-
519
-
520
- def worktree_is_clean(path: str | Path) -> bool:
521
- """True when the worktree at ``path`` has no modified or untracked files.
522
-
523
- Ignored build output (node_modules, .next) does not count as dirty, because
524
- ``git status --porcelain`` does not list ignored paths.
525
- """
526
- completed = run(["git", "-C", str(path), "status", "--porcelain"], check=False, capture=True)
527
- # A non-zero exit means the path is not a working tree at all. Treating
528
- # that as "clean" would let a caller delete a directory it never inspected.
529
- if completed.returncode != 0:
530
- return False
531
- return not (completed.stdout or "").strip()
532
-
533
-
534
- def branch_is_pushed(branch: str, cwd: str | Path | None = None) -> bool:
535
- """True when every commit on ``branch`` is already on its remote.
536
-
537
- Prefers the configured upstream and falls back to ``origin/<branch>``.
538
- """
539
- upstream = run_output(
540
- ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", f"{branch}@{{upstream}}"],
541
- check=False,
542
- cwd=cwd,
543
- ).strip()
544
- if not upstream:
545
- upstream = f"origin/{branch}"
546
-
547
- exists = run(
548
- ["git", "rev-parse", "--verify", "--quiet", upstream], check=False, capture=True, cwd=cwd
549
- )
550
- if exists.returncode != 0:
551
- return False
552
-
553
- ancestor = run(
554
- ["git", "merge-base", "--is-ancestor", branch, upstream],
555
- check=False,
556
- capture=True,
557
- cwd=cwd,
558
- )
559
- return ancestor.returncode == 0
560
-
561
-
562
- def worktree_paths(cwd: str | Path | None = None) -> list[str]:
563
- """Every worktree path in ``git worktree list --porcelain`` order.
564
-
565
- The first entry is always the main worktree; callers drop it themselves so
566
- the distinction stays explicit at the call site.
567
-
568
- The shell version parsed this with ``awk '{print $2}'``, which splits on
569
- whitespace and so truncated any worktree path containing a space. Taking
570
- the whole remainder of the line keeps such paths intact.
571
- """
572
- return [
573
- line.removeprefix("worktree ")
574
- for line in run_output(["git", "worktree", "list", "--porcelain"], cwd=cwd).splitlines()
575
- if line.startswith("worktree ")
576
- ]
577
-
578
-
579
- def main_worktree(cwd: str | Path | None = None) -> str:
580
- """The main worktree's path - the first record git lists, always."""
581
- paths = worktree_paths(cwd)
582
- return paths[0] if paths else ""
583
-
584
-
585
- def find_main_branch(cwd: str | Path | None = None) -> str:
586
- """The local main branch, preferring ``main`` over ``master``, or "" for neither.
587
-
588
- Takes an explicit ``cwd`` because callers run it after moving to the main
589
- worktree; relying on the process working directory would make the answer
590
- depend on where the script happened to be invoked from.
591
- """
592
- for candidate in ("main", "master"):
593
- exists = run(
594
- ["git", "show-ref", "--verify", "--quiet", f"refs/heads/{candidate}"],
595
- check=False,
596
- capture=True,
597
- cwd=cwd,
598
- )
599
- if exists.returncode == 0:
600
- return candidate
601
- return ""
602
-
603
-
604
- # ---------------------------------------------------------------------------
605
- # ffprobe
606
- # ---------------------------------------------------------------------------
607
-
608
-
609
- def ffprobe_entries(
610
- path: Path,
611
- entries: str,
612
- *,
613
- stream: str | None = None,
614
- fmt: str = "csv=p=0",
615
- on_error: str = "",
616
- ) -> str:
617
- """Read metadata out of a media file with ffprobe.
618
-
619
- ``entries`` is passed straight to ``-show_entries`` (e.g.
620
- ``stream=codec_name``). ``stream`` selects a stream when given (e.g.
621
- ``v:0``).
622
-
623
- Returns ``on_error`` when ffprobe itself fails, and "" when it succeeds but
624
- the field is absent. Those are different situations - a corrupt file versus
625
- a file with no audio track - and callers need to tell them apart.
626
- """
627
- argv = ["ffprobe", "-v", "error"]
628
- if stream is not None:
629
- argv += ["-select_streams", stream]
630
- argv += ["-show_entries", entries, "-of", fmt, str(path)]
631
-
632
- completed = run(argv, check=False, capture=True)
633
- if completed.returncode != 0:
634
- return on_error
635
- return (completed.stdout or "").strip()
636
-
637
-
638
- def ffprobe_json(path: Path, argv_extra: Sequence[str]) -> dict:
639
- """Run ffprobe with ``-of json`` and parse the result.
640
-
641
- Returns an empty dict when ffprobe fails or emits unparseable output, so
642
- callers can treat an unreadable file as "no streams" rather than crashing
643
- partway through a large library scan.
644
- """
645
- argv = ["ffprobe", "-v", "error", *argv_extra, "-of", "json", str(path)]
646
- completed = run(argv, check=False, capture=True)
647
- if completed.returncode != 0:
648
- return {}
649
- try:
650
- parsed = json.loads(completed.stdout or "{}")
651
- except json.JSONDecodeError:
652
- return {}
653
- return parsed if isinstance(parsed, dict) else {}
654
-
655
-
656
522
  # ---------------------------------------------------------------------------
657
523
  # Filesystem
658
524
  # ---------------------------------------------------------------------------
659
525
 
660
526
 
661
- def is_macos_metadata(path: Path) -> bool:
662
- """True for the AppleDouble sidecars (``._foo``) the shell scripts skipped.
527
+ def report_walk_error(exc: OSError) -> None:
528
+ """``os.walk``'s ``onerror``: report an unreadable directory and carry on.
663
529
 
664
- ``.DS_Store`` is deliberately not included: the scripts that skip metadata
665
- are matching the ``._*`` pattern specifically.
530
+ find(1) printed "Permission denied" to stderr and carried on. Staying
531
+ silent would make a half-readable SMB share look fully scanned. stderr, not
532
+ the usual :func:`warning`, so these lines never interleave into output a
533
+ caller is piping.
666
534
  """
667
- return path.name.startswith("._")
535
+ print(f"find: {exc.filename}: {exc.strerror}", file=sys.stderr, flush=True)
668
536
 
669
537
 
670
538
  def iter_files(
@@ -708,14 +576,7 @@ def iter_files(
708
576
  if extensions is not None:
709
577
  patterns = tuple(f"*.{ext}" if case_sensitive else f"*.{ext.lower()}" for ext in extensions)
710
578
 
711
- def _on_error(exc: OSError) -> None:
712
- # find(1) printed "Permission denied" to stderr and carried on. Staying
713
- # silent would make a half-readable SMB share look fully scanned.
714
- # stderr, not the usual warning() helper, so these lines never
715
- # interleave into output a caller is piping.
716
- print(f"find: {exc.filename}: {exc.strerror}", file=sys.stderr, flush=True)
717
-
718
- for dirpath, dirnames, filenames in os.walk(root, followlinks=False, onerror=_on_error):
579
+ for dirpath, dirnames, filenames in os.walk(root, followlinks=False, onerror=report_walk_error):
719
580
  dirnames.sort()
720
581
  if skip_hidden:
721
582
  dirnames[:] = [d for d in dirnames if not d.startswith(".")]
@@ -770,6 +631,73 @@ def scan_root(raw: str, *, flag: str = "--path") -> Path:
770
631
  return path
771
632
 
772
633
 
634
+ def _umask() -> int:
635
+ current = os.umask(0)
636
+ os.umask(current)
637
+ return current
638
+
639
+
640
+ def atomic_write_text(path: Path, text: str, *, mode: int | None = None) -> None:
641
+ """Replace ``path``'s contents so a crash leaves the old file or the new one.
642
+
643
+ The text goes to a temporary file beside the target, which is then renamed
644
+ over it. The target is ``path`` resolved, so a symlinked dotfile (a
645
+ ``~/.zshrc`` that points into a dotfiles repo) has its target rewritten and
646
+ the link itself survives. An existing file keeps its permission bits; a new
647
+ one gets ``mode``, or the usual ``0o666`` less the umask where ``mkstemp``
648
+ alone would leave it ``0o600``.
649
+
650
+ UTF-8 with ``surrogateescape``: bytes that are not UTF-8, read the same way,
651
+ are written back exactly as they came in.
652
+
653
+ A hard-linked file is written in place instead. Renaming over one of its
654
+ names would split it from the others, which would keep the old contents.
655
+ """
656
+ target = path.resolve()
657
+ target.parent.mkdir(parents=True, exist_ok=True)
658
+
659
+ if target.exists() and target.stat().st_nlink > 1:
660
+ with target.open("w", encoding="utf-8", errors="surrogateescape") as handle:
661
+ handle.write(text)
662
+ if mode is not None:
663
+ target.chmod(mode)
664
+ return
665
+
666
+ descriptor, temp_name = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.")
667
+ temp = Path(temp_name)
668
+ try:
669
+ with os.fdopen(descriptor, "w", encoding="utf-8", errors="surrogateescape") as handle:
670
+ handle.write(text)
671
+ handle.flush()
672
+ os.fsync(handle.fileno())
673
+ if mode is not None:
674
+ temp.chmod(mode)
675
+ elif target.exists():
676
+ shutil.copymode(target, temp)
677
+ else:
678
+ temp.chmod(0o666 & ~_umask())
679
+ temp.replace(target)
680
+ except BaseException:
681
+ temp.unlink(missing_ok=True)
682
+ raise
683
+
684
+
685
+ def remove_or_warn(path: Path, remove: Callable[[Path], None] = Path.unlink) -> bool:
686
+ """Delete ``path`` (``remove=Path.rmdir`` for a directory), or say why not.
687
+
688
+ Returns whether it went. A failure is reported and left to the caller to
689
+ count: the shell versions aborted the whole run here under ``set -e`` (or,
690
+ in ``delete-smb-files``, hid it with ``2>/dev/null``), and on a NAS one busy
691
+ file should not strand the rest of a sweep.
692
+ """
693
+ try:
694
+ remove(path)
695
+ except OSError as exc:
696
+ warning(f" ✗ Failed to delete {path}: {exc}")
697
+ return False
698
+ return True
699
+
700
+
773
701
  def normalize_extensions(raw: str, *, flag: str = "--ext") -> list[str]:
774
702
  """Split a comma-separated extension list, preserving glob metacharacters.
775
703