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/gui/dnd.py ADDED
@@ -0,0 +1,124 @@
1
+ """
2
+ treeing/gui/dnd.py
3
+
4
+ Defines the Windows file drag-and-drop binding.
5
+ Currently effective on Windows only; on other platforms bind_file_drop is a no-op.
6
+ """
7
+
8
+ import sys
9
+
10
+
11
+ def bind_file_drop(widget, callback) -> None:
12
+ """
13
+ Bind a file drop event to a Tk widget.
14
+
15
+ On drop, calls callback(path: str, extra_files: int). No-op on non-Windows
16
+ platforms.
17
+ """
18
+ if not sys.platform.startswith('win'):
19
+ return
20
+ try:
21
+ _bind_windows_file_drop(widget, callback)
22
+ except (OSError, AttributeError, ValueError):
23
+ pass
24
+
25
+
26
+ def _bind_windows_file_drop(widget, callback) -> None:
27
+ """
28
+ Implement file drop on Windows by subclassing the window procedure.
29
+
30
+ Uses the WM_DROPFILES message and the DragQueryFileW API.
31
+ """
32
+ import ctypes
33
+ from ctypes import wintypes
34
+
35
+ WM_DROPFILES = 0x0233
36
+ GWL_WNDPROC = -4
37
+
38
+ widget.update_idletasks()
39
+ hwnd = widget.winfo_id()
40
+
41
+ shell32 = ctypes.windll.shell32
42
+ user32 = ctypes.windll.user32
43
+
44
+ WNDPROC = ctypes.WINFUNCTYPE(
45
+ wintypes.LRESULT,
46
+ wintypes.HWND,
47
+ wintypes.UINT,
48
+ wintypes.WPARAM,
49
+ wintypes.LPARAM,
50
+ )
51
+
52
+ if hasattr(user32, 'GetWindowLongPtrW'):
53
+ get_wndproc = user32.GetWindowLongPtrW
54
+ set_wndproc = user32.SetWindowLongPtrW
55
+ else:
56
+ get_wndproc = user32.GetWindowLongW
57
+ set_wndproc = user32.SetWindowLongW
58
+
59
+ old_wndproc = get_wndproc(hwnd, GWL_WNDPROC)
60
+
61
+ def py_drop_handler(hwnd, msg, wparam, lparam):
62
+ """Handle WM_DROPFILES: extract the dropped file paths and callback with the first one."""
63
+ if msg == WM_DROPFILES:
64
+ count = shell32.DragQueryFileW(wparam, 0xFFFFFFFF, None, 0)
65
+ paths: list[str] = []
66
+ for i in range(count):
67
+ buf = ctypes.create_unicode_buffer(260)
68
+ needed = shell32.DragQueryFileW(wparam, i, buf, len(buf))
69
+ if needed >= len(buf) - 1:
70
+ buf = ctypes.create_unicode_buffer(needed + 1)
71
+ shell32.DragQueryFileW(wparam, i, buf, len(buf))
72
+ paths.append(buf.value)
73
+ shell32.DragFinish(wparam)
74
+ if paths:
75
+ callback(paths[0], max(0, len(paths) - 1))
76
+ return 0
77
+ return user32.CallWindowProcW(old_wndproc, hwnd, msg, wparam, lparam)
78
+
79
+ new_wndproc = WNDPROC(py_drop_handler)
80
+ set_wndproc(hwnd, GWL_WNDPROC, new_wndproc)
81
+ shell32.DragAcceptFiles(hwnd, True)
82
+
83
+ widget._treeing_drop_old_wndproc = old_wndproc
84
+ widget._treeing_drop_wndproc = new_wndproc
85
+ widget._treeing_drop_hwnd = hwnd
86
+ widget._treeing_drop_user32 = user32
87
+ widget._treeing_drop_shell32 = shell32
88
+ widget._treeing_drop_gwl = GWL_WNDPROC
89
+
90
+ def _on_destroy(event) -> None:
91
+ """On window destruction, restore the original WNDPROC and revoke drag-drop to avoid dangling callbacks."""
92
+ w = event.widget
93
+ drop_hwnd = getattr(w, '_treeing_drop_hwnd', None)
94
+ if drop_hwnd is None:
95
+ return
96
+ try:
97
+ drop_user32 = w._treeing_drop_user32
98
+ drop_shell32 = w._treeing_drop_shell32
99
+ if hasattr(drop_user32, 'SetWindowLongPtrW'):
100
+ set_proc = drop_user32.SetWindowLongPtrW
101
+ get_proc = drop_user32.GetWindowLongPtrW
102
+ else:
103
+ set_proc = drop_user32.SetWindowLongW
104
+ get_proc = drop_user32.GetWindowLongW
105
+ current = get_proc(drop_hwnd, w._treeing_drop_gwl)
106
+ if current == w._treeing_drop_wndproc:
107
+ set_proc(drop_hwnd, w._treeing_drop_gwl, w._treeing_drop_old_wndproc)
108
+ drop_shell32.DragAcceptFiles(drop_hwnd, False)
109
+ except (OSError, AttributeError, ValueError):
110
+ pass
111
+ for attr in (
112
+ '_treeing_drop_old_wndproc',
113
+ '_treeing_drop_wndproc',
114
+ '_treeing_drop_hwnd',
115
+ '_treeing_drop_user32',
116
+ '_treeing_drop_shell32',
117
+ '_treeing_drop_gwl',
118
+ ):
119
+ try:
120
+ delattr(w, attr)
121
+ except AttributeError:
122
+ pass
123
+
124
+ widget.bind('<Destroy>', _on_destroy, add='+')
treeing/gui/icon.py ADDED
@@ -0,0 +1,69 @@
1
+ """
2
+ treeing/gui/icon.py
3
+
4
+ Defines the window-icon logic.
5
+ Supports Windows (.ico), macOS (.icns) and a PNG fallback.
6
+ """
7
+
8
+ import sys
9
+ import tkinter as tk
10
+
11
+ from ..config import _resource_dir
12
+
13
+
14
+ def set_window_icon(root: tk.Misc) -> None:
15
+ """
16
+ Set the window and (macOS) Dock icon.
17
+
18
+ Tk cannot use an emoji string directly; a resource file must be loaded.
19
+ """
20
+ assets = _resource_dir() / "assets"
21
+
22
+ if sys.platform == "darwin":
23
+ icns = assets / "icon.icns"
24
+ if icns.is_file():
25
+ try:
26
+ root.iconbitmap(str(icns))
27
+ return
28
+ except tk.TclError:
29
+ pass
30
+
31
+ if sys.platform.startswith("win"):
32
+ ico = assets / "icon.ico"
33
+ if ico.is_file():
34
+ try:
35
+ root.iconbitmap(str(ico))
36
+ return
37
+ except tk.TclError:
38
+ pass
39
+
40
+ png = assets / "icon.png"
41
+ if not png.is_file():
42
+ return
43
+ try:
44
+ img = tk.PhotoImage(master=root, file=str(png))
45
+ except (tk.TclError, RuntimeError):
46
+ return
47
+ root.iconphoto(True, img)
48
+ root._treeing_icon = img # keep a reference so it is not garbage-collected
49
+
50
+
51
+ def apply_window_icon(widget: tk.Misc, *, icon_source: tk.Misc | None = None) -> None:
52
+ """
53
+ Give a Toplevel sub-window a title-bar icon matching the main window.
54
+
55
+ Reuses the PhotoImage already loaded on icon_source or a parent window to
56
+ avoid reloading; falls back to loading from the resource directory.
57
+ """
58
+ if icon_source is not None and getattr(icon_source, '_treeing_icon', None) is not None:
59
+ widget.iconphoto(True, icon_source._treeing_icon)
60
+ return
61
+
62
+ candidate = icon_source or widget
63
+ while candidate is not None:
64
+ if getattr(candidate, '_treeing_icon', None) is not None:
65
+ widget.iconphoto(True, candidate._treeing_icon)
66
+ return
67
+ candidate = getattr(candidate, 'master', None)
68
+
69
+ set_window_icon(widget)
treeing/gui/preview.py ADDED
@@ -0,0 +1,9 @@
1
+ """treeing/gui/preview.py
2
+
3
+ Re-exports the node display-name formatter used by the GUI preview pane.
4
+ Directly reuses core.preview.format_preview_label.
5
+ """
6
+
7
+ from ..core.preview import format_preview_label
8
+
9
+ __all__ = ['format_preview_label']
@@ -0,0 +1,80 @@
1
+ """
2
+ treeing/gui/settings.py
3
+
4
+ Defines GUI user-preference persistence.
5
+ Saves/loads the last generate directory, font size, import encoding and other
6
+ settings to ~/.treeing/settings.json.
7
+ """
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+ _SETTINGS_PATH = Path.home() / '.treeing' / 'settings.json'
13
+ _DEFAULT_FONT_SIZE = 10
14
+ _DEFAULT_IMPORT_ENCODING = 'utf-8'
15
+ _DEFAULT_IMPORT_ENCODINGS = (
16
+ 'utf-8', 'utf-16', 'cp1252', 'iso-8859-1', 'mac-roman',
17
+ )
18
+
19
+
20
+ def load_settings() -> dict:
21
+ """Load user settings; return an empty dict on failure."""
22
+ try:
23
+ if _SETTINGS_PATH.is_file():
24
+ return json.loads(_SETTINGS_PATH.read_text(encoding='utf-8'))
25
+ except (OSError, json.JSONDecodeError, TypeError):
26
+ pass
27
+ return {}
28
+
29
+
30
+ def save_settings(data: dict) -> None:
31
+ """Save user settings, creating the parent directory as needed."""
32
+ try:
33
+ _SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
34
+ _SETTINGS_PATH.write_text(
35
+ json.dumps(data, ensure_ascii=False, indent=2),
36
+ encoding='utf-8',
37
+ )
38
+ except OSError:
39
+ pass
40
+
41
+
42
+ def get_font_size(settings: dict | None = None) -> int:
43
+ """Return a valid font size in the range 8-24; fall back to the default for invalid values."""
44
+ raw = (settings or load_settings()).get('font_size', _DEFAULT_FONT_SIZE)
45
+ try:
46
+ size = int(raw)
47
+ except (TypeError, ValueError):
48
+ return _DEFAULT_FONT_SIZE
49
+ return max(8, min(size, 24))
50
+
51
+
52
+ def get_last_generate_dir(settings: dict | None = None) -> str | None:
53
+ """Return the directory used for the last generation, or None when it no longer exists."""
54
+ path = (settings or load_settings()).get('last_generate_dir')
55
+ if path and Path(path).is_dir():
56
+ return path
57
+ return None
58
+
59
+
60
+ def get_import_encodings(settings: dict | None = None) -> list[str]:
61
+ """Dropdown preset list; settings.json's import_encodings may add or remove entries; empty falls back to the built-in default."""
62
+ raw = (settings or load_settings()).get('import_encodings')
63
+ if isinstance(raw, list) and raw:
64
+ seen: list[str] = []
65
+ for item in raw:
66
+ if isinstance(item, str):
67
+ enc = item.strip()
68
+ if enc and enc not in seen:
69
+ seen.append(enc)
70
+ if seen:
71
+ return seen
72
+ return list(_DEFAULT_IMPORT_ENCODINGS)
73
+
74
+
75
+ def get_import_encoding(settings: dict | None = None) -> str:
76
+ """Return the last chosen import encoding; default utf-8."""
77
+ raw = (settings or load_settings()).get('import_encoding', _DEFAULT_IMPORT_ENCODING)
78
+ if isinstance(raw, str) and raw.strip():
79
+ return raw.strip()
80
+ return _DEFAULT_IMPORT_ENCODING
treeing/gui/tooltip.py ADDED
@@ -0,0 +1,234 @@
1
+ """
2
+ treeing/gui/tooltip.py
3
+
4
+ Defines the hover-tooltip component.
5
+ Supports showing explanatory text on Labels, buttons and grouped checkboxes.
6
+ """
7
+
8
+ import tkinter as tk
9
+ from typing import Literal
10
+
11
+ from ..config import get_string
12
+
13
+ _DEFAULT_WRAP = 420
14
+ _WINDOW_MARGIN = 8
15
+ _DEFAULT_H_OFFSET = 16
16
+ _RIGHT_HALF_INSET = 8
17
+ _TOOLTIP_OFFSET = 4
18
+ _TooltipPosition = Literal['below', 'above']
19
+
20
+
21
+ class ToolTip:
22
+ """
23
+ A tooltip component that shows explanatory text on hover.
24
+ Supports custom position (below/above), wrap width and a root window.
25
+
26
+ MING changed the Toplevel to "lazy-create + reuse": each ToolTip instance
27
+ holds at most one Toplevel, deiconified on hover and withdrawn on leave,
28
+ instead of creating and destroying one each time. This works around
29
+ macOS-arm, where repeatedly creating/destroying override-redirect windows
30
+ leaves white rectangles (ghost frames) because WindowServer cannot clear
31
+ them in time.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ widget,
37
+ text: str,
38
+ *,
39
+ root: tk.Misc | None = None,
40
+ wraplength: int = _DEFAULT_WRAP,
41
+ position: _TooltipPosition = 'below',
42
+ ) -> None:
43
+ """
44
+ Initialise the tooltip and bind the widget's Enter/Leave events.
45
+
46
+ `root` is used to compute the tooltip position; when omitted, the
47
+ top-level window holding the widget is used. No window is created
48
+ here; the real creation happens on first _show.
49
+ """
50
+ self.widget = widget
51
+ self.text = text
52
+ self.root = root
53
+ self.wraplength = wraplength
54
+ self.position = position
55
+ self.tip_window: tk.Toplevel | None = None
56
+ self._label: tk.Label | None = None
57
+ widget.bind('<Enter>', self._show, add='+')
58
+ widget.bind('<Leave>', self._hide, add='+')
59
+
60
+ def _ensure_window(self, root: tk.Misc) -> tk.Toplevel:
61
+ """
62
+ Lazily create and reuse a single Toplevel window.
63
+
64
+ On first call, creates the Toplevel, sets it topmost, and packs a
65
+ Label inside; later calls return the existing window. The window
66
+ persists; _hide only withdraws it (no destroy), avoiding the ghost
67
+ frames caused by repeated create/destroy on macOS-arm.
68
+ """
69
+ if self.tip_window is not None:
70
+ return self.tip_window
71
+
72
+ tw = tk.Toplevel(root)
73
+ tw.withdraw()
74
+ tw.wm_overrideredirect(True)
75
+ # Topmost keeps the tooltip above the main window; some platforms /
76
+ # window managers do not support this attribute, so failures are
77
+ # silently ignored and do not affect display.
78
+ try:
79
+ tw.wm_attributes('-topmost', True)
80
+ except tk.TclError:
81
+ pass
82
+ self._label = tk.Label(
83
+ tw,
84
+ text=self.text,
85
+ justify=tk.LEFT,
86
+ background='#ffffe0',
87
+ relief=tk.SOLID,
88
+ borderwidth=1,
89
+ padx=8,
90
+ pady=6,
91
+ wraplength=self.wraplength,
92
+ )
93
+ self._label.pack()
94
+ self.tip_window = tw
95
+ return tw
96
+
97
+ def _place_tooltip(self, root: tk.Misc, tw: tk.Toplevel) -> None:
98
+ """
99
+ Compute the tooltip's screen coordinates and move the window.
100
+
101
+ Horizontal: if the widget is to the right of the centre line, right-align
102
+ with a left inset; otherwise left-align; then clamp within the root
103
+ window's left/right edges.
104
+ Vertical: above or below per `position`; if the target side does not fit
105
+ (above the top or below the bottom of the screen), flip to the other
106
+ side so the tooltip never leaves the visible area.
107
+
108
+ Height prefers winfo_reqheight because, while the window is withdrawn,
109
+ winfo_height may return 1 on macOS-arm Tk (not actually mapped), which
110
+ would make an "above" tooltip stick to the widget top and overflow
111
+ downward over the button.
112
+ """
113
+ root.update_idletasks()
114
+ tw.update_idletasks()
115
+
116
+ root_x = root.winfo_rootx()
117
+ root_w = root.winfo_width()
118
+ root_right = root_x + root_w
119
+
120
+ wx = self.widget.winfo_rootx()
121
+ wy = self.widget.winfo_rooty()
122
+ ww = max(self.widget.winfo_width(), 1)
123
+ wh = max(self.widget.winfo_height(), 1)
124
+
125
+ tip_w = max(_to_int(tw.winfo_reqwidth(), 0), _to_int(tw.winfo_width(), 0), 1)
126
+ tip_h = max(_to_int(tw.winfo_reqheight(), 0), _to_int(tw.winfo_height(), 0), 1)
127
+
128
+ window_cx = root_x + root_w // 2
129
+ widget_cx = wx + ww // 2
130
+
131
+ if widget_cx >= window_cx:
132
+ x = wx + ww - tip_w - _RIGHT_HALF_INSET
133
+ else:
134
+ x = wx + _DEFAULT_H_OFFSET
135
+
136
+ x = max(root_x + _WINDOW_MARGIN, min(x, root_right - tip_w - _WINDOW_MARGIN))
137
+
138
+ y_above = wy - tip_h - _TOOLTIP_OFFSET
139
+ y_below = wy + wh + _TOOLTIP_OFFSET
140
+
141
+ if self.position == 'above':
142
+ y = y_above
143
+ if y < _WINDOW_MARGIN:
144
+ y = y_below
145
+ else:
146
+ y = y_below
147
+ screen_h = _safe_screen_height(root)
148
+ if screen_h and y + tip_h > screen_h - _WINDOW_MARGIN:
149
+ y = y_above
150
+
151
+ tw.wm_geometry(f'+{x}+{y}')
152
+
153
+ def _show(self, _event=None) -> None:
154
+ """
155
+ Show the tooltip when the mouse enters the widget.
156
+
157
+ Reuses the existing window; refreshes the Label if the text changed
158
+ (may happen when one instance is bound to multiple widgets). Positions
159
+ before deiconify, so it does not appear at the default position and
160
+ then jump (which causes flicker / ghost frames).
161
+ """
162
+ if not self.text:
163
+ return
164
+ root = self.root or self.widget.winfo_toplevel()
165
+ tw = self._ensure_window(root)
166
+ if self._label is not None:
167
+ self._label.configure(text=self.text)
168
+ self._place_tooltip(root, tw)
169
+ try:
170
+ tw.deiconify()
171
+ except tk.TclError:
172
+ # Silently skip this show when the parent window has been destroyed.
173
+ self.tip_window = None
174
+ self._label = None
175
+
176
+ def _hide(self, _event=None) -> None:
177
+ """
178
+ Hide the tooltip when the mouse leaves the widget.
179
+
180
+ Withdraw only, never destroy: the window stays for the next hover,
181
+ which from the source eliminates the ghost frames caused by repeatedly
182
+ destroying override-redirect windows on macOS-arm.
183
+ """
184
+ if self.tip_window is not None:
185
+ try:
186
+ self.tip_window.withdraw()
187
+ except tk.TclError:
188
+ self.tip_window = None
189
+ self._label = None
190
+
191
+
192
+ def _to_int(value, fallback: int) -> int:
193
+ """
194
+ Safely convert a Tk geometry value to int, returning fallback on failure.
195
+
196
+ Under mocks or abnormal Tk states, winfo_* may return a non-int (MagicMock,
197
+ empty string, etc.); int() would raise TypeError. This wrapper guards the
198
+ coordinate calculations so they never abort.
199
+ """
200
+ try:
201
+ return int(value)
202
+ except (TypeError, ValueError):
203
+ return fallback
204
+
205
+
206
+ def _safe_screen_height(root: tk.Misc) -> int | None:
207
+ """
208
+ Return the screen height of the root window's screen, or None on failure / non-int.
209
+
210
+ Tk may raise TclError for winfo_screenheight in some abnormal states, and
211
+ under mocks the return value is not an int; callers skip the below-flip
212
+ check in that case.
213
+ """
214
+ try:
215
+ value = root.winfo_screenheight()
216
+ except tk.TclError:
217
+ return None
218
+ if isinstance(value, int):
219
+ return value
220
+ return None
221
+
222
+
223
+ def bind_tooltip(
224
+ widget,
225
+ string_key: str,
226
+ *,
227
+ root: tk.Misc | None = None,
228
+ position: _TooltipPosition = 'below',
229
+ **fmt,
230
+ ) -> ToolTip:
231
+ """Bind a tooltip with text from strings.json to a widget."""
232
+ return ToolTip(
233
+ widget, get_string(string_key, **fmt), root=root, position=position,
234
+ )
treeing/gui_entry.py ADDED
@@ -0,0 +1,39 @@
1
+ """treeing/gui_entry.py
2
+
3
+ PyInstaller GUI entry point.
4
+ Creates the Tk root and starts TreeingApp, catching ConfigError so the packaged app still exits cleanly.
5
+ """
6
+ import sys
7
+
8
+ from treeing.config import ConfigError, get_ui_string
9
+
10
+
11
+ def run_gui() -> int:
12
+ """
13
+ Create the Tk main window and run TreeingApp.
14
+
15
+ If the current environment is missing Tcl/Tk, print an error message and
16
+ return 1, rather than raising an opaque TclError.
17
+ """
18
+ import tkinter as tk
19
+
20
+ from treeing.gui.app import TreeingApp
21
+
22
+ try:
23
+ root = tk.Tk()
24
+ except tk.TclError as exc:
25
+ print(get_ui_string('gui_err_tcl_missing'), file=sys.stderr)
26
+ print(f"Underlying error: {exc}", file=sys.stderr)
27
+ return 1
28
+
29
+ TreeingApp(root)
30
+ root.mainloop()
31
+ return 0
32
+
33
+
34
+ if __name__ == '__main__':
35
+ try:
36
+ sys.exit(run_gui())
37
+ except ConfigError as e:
38
+ print(str(e), file=sys.stderr)
39
+ sys.exit(1)
treeing/main.py ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env python3
2
+ """treeing/main.py
3
+
4
+ Unified entry script for development.
5
+ Starts the GUI with no arguments, or runs the CLI with arguments; a ConfigError is caught and reported with a non-zero exit code.
6
+ """
7
+ import sys
8
+
9
+ from treeing.cli.main import cli_main
10
+ from treeing.config import ConfigError
11
+ from treeing.gui_entry import run_gui
12
+
13
+ if __name__ == '__main__':
14
+ try:
15
+ if len(sys.argv) > 1:
16
+ sys.exit(cli_main())
17
+ else:
18
+ run_gui()
19
+ except ConfigError as e:
20
+ print(str(e), file=sys.stderr)
21
+ sys.exit(1)
treeing/path_checks.py ADDED
@@ -0,0 +1,87 @@
1
+ """treeing/path_checks.py
2
+
3
+ Defines output-path validation logic.
4
+ Provides `verify_output_is_directory` and `verify_output_writable`,
5
+ shared by the CLI and GUI, to ensure the target directory is legal and writable.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import tempfile
12
+ from pathlib import Path
13
+
14
+ from .config import get_string
15
+
16
+
17
+ def verify_output_is_directory(output: str | Path) -> str | None:
18
+ """
19
+ Check whether the output path is legal.
20
+
21
+ Returns an error message if the path exists and is a file; otherwise None.
22
+ """
23
+ target = Path(output).expanduser()
24
+ if target.exists() and target.is_file():
25
+ return get_string('cli_output_is_file', path=target)
26
+ return None
27
+
28
+
29
+ def _nearest_existing(path: Path) -> Path | None:
30
+ """Walk upward from path to the nearest existing parent directory; return None if none exists."""
31
+ probe = path
32
+ while not probe.exists():
33
+ parent = probe.parent
34
+ if parent == probe:
35
+ return None
36
+ probe = parent
37
+ return probe
38
+
39
+
40
+ def _probe_writable_dir(dir_path: Path, report_path: Path) -> str | None:
41
+ """
42
+ Actually probe whether a directory is writable.
43
+
44
+ Checks os.access first, then tries to create a temporary directory as a
45
+ second safeguard. MING added the temporary-directory test because some
46
+ mount/network paths report writable via access but fail on real writes.
47
+ """
48
+ try:
49
+ resolved = dir_path.resolve()
50
+ except OSError:
51
+ return get_string('cli_output_not_writable', path=report_path)
52
+
53
+ if not resolved.is_dir():
54
+ return get_string('cli_output_not_writable', path=report_path)
55
+
56
+ if not os.access(resolved, os.W_OK):
57
+ return get_string('cli_output_not_writable', path=resolved)
58
+ if os.name != 'nt' and not os.access(resolved, os.X_OK):
59
+ return get_string('cli_output_not_writable', path=resolved)
60
+
61
+ try:
62
+ test_dir = tempfile.mkdtemp(prefix='.treeing-write-test-', dir=str(resolved))
63
+ os.rmdir(test_dir)
64
+ except OSError:
65
+ return get_string('cli_output_not_writable', path=report_path)
66
+ return None
67
+
68
+
69
+ def verify_output_writable(output: str | Path) -> str | None:
70
+ """
71
+ Check whether the output path is ultimately writable.
72
+
73
+ If the target directory does not exist, walk up to the nearest existing
74
+ parent before probing.
75
+ """
76
+ err = verify_output_is_directory(output)
77
+ if err:
78
+ return err
79
+
80
+ target = Path(output).expanduser()
81
+ if target.exists():
82
+ return _probe_writable_dir(target, target)
83
+
84
+ existing = _nearest_existing(target)
85
+ if existing is None:
86
+ return get_string('cli_output_not_writable', path=output)
87
+ return _probe_writable_dir(existing, target)
@@ -0,0 +1,7 @@
1
+ {
2
+ "config_err_missing": "Could not find config file strings.json: {path}",
3
+ "config_err_invalid_json": "strings.json has invalid JSON: {path} ({error})",
4
+ "config_err_read": "Could not read config file strings.json: {path} ({error})",
5
+ "config_err_not_object": "strings.json root must be a JSON object: {path}",
6
+ "config_err_bootstrap_missing": "Could not load the bootstrap strings file strings.bootstrap.json: {path}"
7
+ }