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 ADDED
@@ -0,0 +1,7 @@
1
+ """treeing
2
+
3
+ Top-level package entry point for the Treeing project.
4
+ Exposes the version string and core submodules (core, cli, gui) for external imports.
5
+ """
6
+
7
+ __version__ = "1.0.1"
@@ -0,0 +1,3 @@
1
+ # Default locale for this release tree.
2
+ # Set during release preparation; do not edit in the release repository.
3
+ DEFAULT_LOCALE = 'en'
Binary file
Binary file
Binary file
@@ -0,0 +1,5 @@
1
+ """treeing/cli
2
+
3
+ Command-line interface layer.
4
+ Provides argument parsing, the help system, path validation, confirmation prompts, result reporting and the main entry point.
5
+ """
treeing/cli/confirm.py ADDED
@@ -0,0 +1,197 @@
1
+ """
2
+ treeing/cli/confirm.py
3
+
4
+ Defines the pre-write confirmation logic: interactive --confirm, headless
5
+ --yes, and the TREEING_YES environment variable. Provides
6
+ `gate_before_write` as the unified entry point, handling TTY detection,
7
+ confirmation prompts and mode resolution.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import sys
14
+
15
+ from ..config import get_string
16
+ from .io import cli_err, cli_out, cli_warn
17
+
18
+ ENV_ASSUME_YES = 'TREEING_YES'
19
+
20
+
21
+ def assume_yes_from_env() -> bool:
22
+ """
23
+ Check whether the TREEING_YES environment variable is truthy (1/true/yes/on).
24
+
25
+ Used by gate_before_write to auto-confirm in non-interactive or CI
26
+ environments; the check is case-insensitive.
27
+ """
28
+ val = os.environ.get(ENV_ASSUME_YES, '').strip().lower()
29
+ if not val:
30
+ return False
31
+ return val in ('1', 'true', 'yes', 'on')
32
+
33
+
34
+ def is_interactive_tty() -> bool:
35
+ """
36
+ Return whether the current session is an interactive TTY (both stdin and stderr are ttys).
37
+
38
+ Only treats the session as interactive when both are ttys; returns False
39
+ under pipes or redirection.
40
+ """
41
+ return bool(sys.stdin.isatty() and sys.stderr.isatty())
42
+
43
+
44
+ def read_yes_no(*, default: bool = False) -> bool:
45
+ """
46
+ Read a y/n answer from the user; the default is controlled by `default`.
47
+
48
+ Windows uses msvcrt; Unix uses /dev/tty.
49
+ """
50
+ suffix = '[Y/n] ' if default else '[y/N] '
51
+ cli_err(get_string('cli_confirm_prompt', suffix=suffix), end='')
52
+ if sys.platform == 'win32':
53
+ return _read_yes_no_windows(default=default)
54
+ return _read_yes_no_unix(default=default)
55
+
56
+
57
+ def _read_yes_no_windows(*, default: bool) -> bool:
58
+ """
59
+ Read a single y/n character on Windows (no Enter needed).
60
+
61
+ Accepts only y/n; Enter falls back to the default; other keys keep waiting.
62
+ """
63
+ import msvcrt
64
+
65
+ while True:
66
+ ch = msvcrt.getwch()
67
+ if ch in '\r\n':
68
+ cli_err('')
69
+ return default
70
+ lower = ch.lower()
71
+ if lower in ('y', 'n'):
72
+ cli_err(ch)
73
+ return lower == 'y'
74
+
75
+
76
+ def _read_yes_no_unix(*, default: bool) -> bool:
77
+ """
78
+ Read a line on Unix (via /dev/tty to avoid pipe interference).
79
+
80
+ Returns the default on a blank line or read failure; only the first
81
+ character is consulted.
82
+ """
83
+ try:
84
+ with open('/dev/tty', encoding='utf-8', errors='replace') as tty:
85
+ line = tty.readline()
86
+ except OSError:
87
+ return default
88
+ answer = line.strip().lower()
89
+ if not answer:
90
+ return default
91
+ return answer in ('y', 'yes')
92
+
93
+
94
+ def warn_confirm_ignored_on_dry_run() -> None:
95
+ """
96
+ Warn that --confirm is ignored under --dry-run.
97
+
98
+ Because dry-run writes nothing, asking the user to confirm is pointless.
99
+ """
100
+ cli_warn(get_string('cli_confirm_dry_run_ignored'))
101
+
102
+
103
+ def audit_yes_confirmed(*, path: str, node_count: int, quiet: bool) -> None:
104
+ """
105
+ In headless mode (--yes / env), print a confirmation summary.
106
+
107
+ Even though the interactive prompt is skipped, the log still shows
108
+ "confirmed: N nodes will be created".
109
+ """
110
+ if quiet:
111
+ return
112
+ cli_out(get_string('cli_confirm_yes_audit', count=node_count, path=path))
113
+
114
+
115
+ def prompt_generate_confirm(
116
+ *,
117
+ path: str,
118
+ node_count: int,
119
+ fail_on_conflict: bool,
120
+ warn_count: int,
121
+ ) -> bool:
122
+ """
123
+ In interactive mode, print the generation confirmation prompt and read the user's answer.
124
+
125
+ Returns True if the user confirms, False if cancelled.
126
+ """
127
+ cli_err(get_string(
128
+ 'cli_msg_generate_confirm',
129
+ path=path,
130
+ count=node_count,
131
+ conflict_hint=get_string(
132
+ 'cli_confirm_conflict_enabled' if fail_on_conflict
133
+ else 'cli_confirm_conflict_disabled',
134
+ ),
135
+ warn_hint=get_string(
136
+ 'cli_confirm_warn_count', count=warn_count,
137
+ ) if warn_count else get_string('cli_confirm_no_warnings'),
138
+ ))
139
+ return read_yes_no(default=False)
140
+
141
+
142
+ def resolve_confirm_mode(args) -> str | None:
143
+ """
144
+ Resolve the confirmation mode from the arguments: yes / env / interactive / None.
145
+
146
+ Priority: --yes > TREEING_YES > --confirm.
147
+ """
148
+ if getattr(args, 'yes', False):
149
+ return 'yes'
150
+ if assume_yes_from_env():
151
+ return 'env'
152
+ if getattr(args, 'confirm', False):
153
+ return 'interactive'
154
+ return None
155
+
156
+
157
+ def gate_before_write(
158
+ args,
159
+ *,
160
+ path: str,
161
+ node_count: int,
162
+ fail_on_conflict: bool,
163
+ warn_count: int,
164
+ ) -> tuple[int | None, str | None]:
165
+ """
166
+ Pre-write confirmation entry point.
167
+
168
+ Returns (early_exit_code, confirm_mode). A non-None early_exit_code means
169
+ the run should exit early and skip writing.
170
+ """
171
+ if args.dry_run:
172
+ if args.confirm:
173
+ warn_confirm_ignored_on_dry_run()
174
+ return None, None
175
+
176
+ mode = resolve_confirm_mode(args)
177
+ if mode in ('yes', 'env'):
178
+ audit_yes_confirmed(path=path, node_count=node_count, quiet=args.quiet)
179
+ return None, mode
180
+
181
+ if not args.confirm:
182
+ return None, None
183
+
184
+ if not is_interactive_tty():
185
+ cli_err(get_string('cli_error_confirm_not_tty'))
186
+ return 1, None
187
+
188
+ if not prompt_generate_confirm(
189
+ path=path,
190
+ node_count=node_count,
191
+ fail_on_conflict=fail_on_conflict,
192
+ warn_count=warn_count,
193
+ ):
194
+ cli_err(get_string('cli_confirm_cancelled'))
195
+ return 0, None
196
+
197
+ return None, 'interactive'
@@ -0,0 +1,311 @@
1
+ """
2
+ treeing/cli/help_text.py
3
+
4
+ Defines the layered CLI help system: short help, full help, topic help, and about.
5
+ Provides `build_parser` and `dispatch_help_argv` as entry points.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ from collections.abc import Callable
12
+
13
+ from .. import __version__
14
+ from ..config import get_string, get_ui_string
15
+ from .io import cli_out
16
+
17
+ # topic name -> (title string key, body string key or None to use gui_tooltip_*)
18
+ TOPIC_SPECS: dict[str, tuple[str, str | None, str | None]] = {
19
+ # name: (title_key, body_key, tooltip_fallback_key)
20
+ 'input': ('cli_help_topic_title_input', 'cli_help_topic_input', 'gui_tooltip_input'),
21
+ 'output': ('cli_help_topic_title_output', 'cli_help_topic_output', None),
22
+ 'encoding': ('cli_help_topic_title_encoding', None, 'gui_tooltip_encoding'),
23
+ 'parse': ('cli_help_topic_title_parse', 'cli_help_topic_parse', None),
24
+ 'no-fix': ('cli_help_topic_title_no_fix', None, 'gui_tooltip_no_fix'),
25
+ 'strict-dirs': ('cli_help_topic_title_strict_dirs', None, 'gui_tooltip_strict_dirs'),
26
+ 'indent-unit': ('cli_help_topic_title_indent_unit', None, 'gui_tooltip_indent_unit'),
27
+ 'dry-run': ('cli_help_topic_title_dry_run', 'cli_help_topic_dry_run', None),
28
+ 'allow-nested': ('cli_help_topic_title_allow_nested', None, 'gui_tooltip_allow_nested'),
29
+ 'fail-on-conflict': ('cli_help_topic_title_fail_on_conflict', None, 'gui_tooltip_fail_on_conflict'),
30
+ 'rollback-on-error': ('cli_help_topic_title_rollback_on_error', 'cli_help_topic_rollback_on_error', None),
31
+ 'format': ('cli_help_topic_title_format', 'cli_help_topic_format', 'gui_tooltip_preview'),
32
+ 'json': ('cli_help_topic_title_json', 'cli_help_topic_json', None),
33
+ 'automation': ('cli_help_topic_title_automation', 'cli_help_topic_automation', None),
34
+ 'windows': ('cli_help_topic_title_windows', 'cli_help_topic_windows', None),
35
+ 'warnings': ('cli_help_topic_title_warnings', None, 'gui_tooltip_view_warnings'),
36
+ 'strict': ('cli_help_topic_title_strict', 'cli_help_topic_strict', None),
37
+ 'confirm': ('cli_help_topic_title_confirm', 'cli_help_topic_confirm', None),
38
+ 'about': ('cli_help_topic_title_about', None, None),
39
+ }
40
+
41
+ # P3: topic index groups (section_key, [topic names])
42
+ TOPIC_INDEX: list[tuple[str, list[str]]] = [
43
+ ('cli_help_topics_section_input', ['input', 'output', 'encoding']),
44
+ ('cli_help_topics_section_parse', ['parse', 'no-fix', 'strict-dirs', 'indent-unit']),
45
+ ('cli_help_topics_section_generate', [
46
+ 'dry-run', 'allow-nested', 'fail-on-conflict', 'rollback-on-error',
47
+ 'format', 'strict', 'confirm',
48
+ ]),
49
+ ('cli_help_topics_section_automation', ['json', 'automation', 'warnings']),
50
+ ('cli_help_topics_section_platform', ['windows']),
51
+ ('cli_help_topics_section_meta', ['about']),
52
+ ]
53
+
54
+ TOPIC_ALIASES: dict[str, str] = {
55
+ 'i': 'input',
56
+ 'o': 'output',
57
+ 'p': 'paste',
58
+ 'paste': 'input',
59
+ 'nested': 'allow-nested',
60
+ 'conflict': 'fail-on-conflict',
61
+ 'rollback': 'rollback-on-error',
62
+ 'yes': 'confirm',
63
+ 'y': 'confirm',
64
+ 'topics': 'topics',
65
+ }
66
+
67
+
68
+ def _body_for_topic(name: str) -> str:
69
+ """
70
+ Fetch the body text for a help topic.
71
+
72
+ Prefer the dedicated body_key; otherwise fall back to the gui_tooltip_*
73
+ text. The `about` topic is special-cased and prints the about text.
74
+ """
75
+ if name == 'about':
76
+ return get_ui_string('gui_msg_about', version=__version__)
77
+ spec = TOPIC_SPECS.get(name)
78
+ if not spec:
79
+ return ''
80
+ _, body_key, tooltip_key = spec
81
+ if body_key:
82
+ text = get_string(body_key)
83
+ if text != body_key:
84
+ return text
85
+ if tooltip_key:
86
+ return get_string(tooltip_key)
87
+ return ''
88
+
89
+
90
+ def print_about() -> None:
91
+ """Print the --about content (shares the same text as the GUI about dialogue)."""
92
+ cli_out(get_ui_string('gui_msg_about', version=__version__))
93
+
94
+
95
+ def print_topics_index() -> None:
96
+ """Print the topic index for `treeing help`, grouped by section."""
97
+ cli_out(get_ui_string('cli_help_topics_intro'))
98
+ cli_out('')
99
+ for section_key, names in TOPIC_INDEX:
100
+ cli_out(get_string(section_key))
101
+ for name in names:
102
+ title_key = TOPIC_SPECS[name][0]
103
+ cli_out(get_string('cli_help_topics_item', topic=name, title=get_string(title_key)))
104
+ cli_out('')
105
+ cli_out(get_ui_string('cli_help_topics_footer'))
106
+
107
+
108
+ def print_topic(name: str) -> int:
109
+ """
110
+ Print detailed help for a single topic.
111
+
112
+ Supports aliases (e.g. nested -> allow-nested); an unknown topic prints an
113
+ error first, then lists the available topics.
114
+ """
115
+ canonical = TOPIC_ALIASES.get(name, name)
116
+ if canonical == 'topics':
117
+ print_topics_index()
118
+ return 0
119
+ if canonical not in TOPIC_SPECS:
120
+ cli_out(get_string('cli_help_unknown_topic', topic=name))
121
+ cli_out('')
122
+ print_topics_index()
123
+ return 1
124
+ title = get_string(TOPIC_SPECS[canonical][0])
125
+ body = _body_for_topic(canonical)
126
+ cli_out(title)
127
+ cli_out('')
128
+ cli_out(body)
129
+ cli_out('')
130
+ cli_out(get_ui_string('cli_help_topic_see_also'))
131
+ return 0
132
+
133
+
134
+ def dispatch_help_argv(argv: list[str]) -> int | None:
135
+ """
136
+ Handle help / --about / --help-full requests before formal argument parsing.
137
+
138
+ Returns an exit code when handled; returns None when this is not a help
139
+ request and normal flow should continue.
140
+ """
141
+ if len(argv) == 2 and argv[1] == '--about':
142
+ print_about()
143
+ return 0
144
+ if len(argv) == 2 and argv[1] == '--help-full':
145
+ print_help_full()
146
+ return 0
147
+ try:
148
+ idx = argv.index('help')
149
+ except ValueError:
150
+ return None
151
+ if idx != 1:
152
+ return None
153
+ topic = argv[idx + 1] if len(argv) > idx + 1 else None
154
+ if topic is None:
155
+ print_topics_index()
156
+ return 0
157
+ return print_topic(topic)
158
+
159
+
160
+ def _add_group(parser: argparse.ArgumentParser, title_key: str, add_fn: Callable) -> None:
161
+ """Add an argument group to the parser; the group title comes from the string resources."""
162
+ group = parser.add_argument_group(get_string(title_key))
163
+ add_fn(group)
164
+
165
+
166
+ def build_parser() -> argparse.ArgumentParser:
167
+ """Build and return the CLI argument parser; all text comes from strings.json."""
168
+ parser = argparse.ArgumentParser(
169
+ description=get_string('cli_help_desc'),
170
+ epilog=get_ui_string('cli_help_epilog'),
171
+ formatter_class=argparse.RawDescriptionHelpFormatter,
172
+ )
173
+ parser.add_argument(
174
+ '--version', action='version',
175
+ version=get_string('cli_version_fmt', version=__version__),
176
+ )
177
+ parser.add_argument(
178
+ '--about', action='store_true',
179
+ help=get_string('cli_about_flag'),
180
+ )
181
+ parser.add_argument(
182
+ '--help-full', action='store_true',
183
+ help=get_string('cli_help_full_flag'),
184
+ )
185
+
186
+ def add_input(g):
187
+ """Register input-related arguments: -i/--input, -p/--paste, --encoding."""
188
+ g.add_argument('-i', '--input', help=get_string('cli_input_file'))
189
+ g.add_argument('-p', '--paste', action='store_true', help=get_string('cli_paste'))
190
+ g.add_argument(
191
+ '--encoding', default='utf-8', metavar='ENC',
192
+ help=get_string('cli_encoding'),
193
+ )
194
+
195
+ def add_output(g):
196
+ """Register output-related arguments: -o/--output, --use-settings, --check-writable."""
197
+ g.add_argument('-o', '--output', default=None, help=get_string('cli_output_dir'))
198
+ g.add_argument(
199
+ '--use-settings', action='store_true',
200
+ help=get_string('cli_use_settings'),
201
+ )
202
+ g.add_argument(
203
+ '--check-writable', action='store_true',
204
+ help=get_string('cli_check_writable'),
205
+ )
206
+
207
+ def add_parse(g):
208
+ """Register parse-related arguments: --no-fix, --strict-dirs, --indent-unit."""
209
+ g.add_argument('--no-fix', action='store_true', help=get_string('cli_no_fix'))
210
+ g.add_argument(
211
+ '--strict-dirs', action='store_true',
212
+ help=get_string('cli_strict_dirs'),
213
+ )
214
+ g.add_argument(
215
+ '--indent-unit', type=int, default=None, metavar='N',
216
+ help=get_string('cli_indent_unit'),
217
+ )
218
+
219
+ def add_generate(g):
220
+ """Register generation-related arguments: dry-run, nested names, conflict handling, confirmation mode, etc."""
221
+ g.add_argument('--dry-run', action='store_true', help=get_string('cli_dry_run'))
222
+ g.add_argument(
223
+ '--allow-nested-names', action='store_true',
224
+ help=get_string('cli_allow_nested_names'),
225
+ )
226
+ g.add_argument(
227
+ '--fail-on-conflict', action='store_true',
228
+ help=get_string('cli_fail_on_conflict'),
229
+ )
230
+ g.add_argument(
231
+ '--fail-on-duplicate', action='store_true',
232
+ help=get_string('cli_fail_on_duplicate'),
233
+ )
234
+ g.add_argument(
235
+ '--strict', action='store_true',
236
+ help=get_string('cli_strict'),
237
+ )
238
+ g.add_argument(
239
+ '--rollback-on-error', action='store_true',
240
+ help=get_string('cli_rollback_on_error'),
241
+ )
242
+ confirm = g.add_mutually_exclusive_group()
243
+ confirm.add_argument(
244
+ '--confirm', action='store_true',
245
+ help=get_string('cli_confirm'),
246
+ )
247
+ confirm.add_argument(
248
+ '-y', '--yes', action='store_true',
249
+ help=get_string('cli_yes'),
250
+ )
251
+
252
+ def add_automation(g):
253
+ """Register automation-related arguments: --json, --quiet, --warn-exit-code, --warnings-file, --format, warning cap."""
254
+ g.add_argument('--json', action='store_true', help=get_string('cli_json'))
255
+ g.add_argument('--quiet', action='store_true', help=get_string('cli_quiet'))
256
+ g.add_argument(
257
+ '--warn-exit-code', action='store_true',
258
+ help=get_string('cli_warn_exit_code'),
259
+ )
260
+ g.add_argument(
261
+ '--warnings-file', metavar='PATH',
262
+ help=get_string('cli_warnings_file'),
263
+ )
264
+ g.add_argument(
265
+ '--format', choices=['text', 'tree'], default='text',
266
+ help=get_string('cli_format'),
267
+ )
268
+ warn_limit = g.add_mutually_exclusive_group()
269
+ warn_limit.add_argument(
270
+ '--no-warn-limit', action='store_true',
271
+ help=get_string('cli_no_warn_limit'),
272
+ )
273
+ warn_limit.add_argument(
274
+ '--warn-limit', type=int, default=None, metavar='N',
275
+ help=get_string('cli_warn_limit'),
276
+ )
277
+
278
+ _add_group(parser, 'cli_help_group_input', add_input)
279
+ _add_group(parser, 'cli_help_group_output', add_output)
280
+ _add_group(parser, 'cli_help_group_parse', add_parse)
281
+ _add_group(parser, 'cli_help_group_generate', add_generate)
282
+ _add_group(parser, 'cli_help_group_automation', add_automation)
283
+ return parser
284
+
285
+
286
+ def print_help_full() -> None:
287
+ """Full help: short help plus an expanded description for each option."""
288
+ parser = build_parser()
289
+ parser.print_help()
290
+ cli_out('')
291
+ cli_out(get_string('cli_help_full_header'))
292
+ cli_out('')
293
+ sections = [
294
+ ('cli_help_full_section_input', ['input', 'output', 'encoding']),
295
+ ('cli_help_full_section_parse', ['parse', 'no-fix', 'strict-dirs', 'indent-unit']),
296
+ ('cli_help_full_section_generate', [
297
+ 'dry-run', 'allow-nested', 'fail-on-conflict', 'rollback-on-error', 'format', 'strict', 'confirm',
298
+ ]),
299
+ ('cli_help_full_section_automation', ['json', 'automation', 'warnings']),
300
+ ('cli_help_full_section_platform', ['windows']),
301
+ ]
302
+ for section_key, topics in sections:
303
+ cli_out(get_string(section_key))
304
+ for topic in topics:
305
+ title = get_string(TOPIC_SPECS[topic][0])
306
+ body = _body_for_topic(topic)
307
+ cli_out(get_string('cli_help_full_topic_line', topic=topic, title=title))
308
+ for line in body.splitlines():
309
+ cli_out(f' {line}')
310
+ cli_out('')
311
+ cli_out(get_ui_string('cli_help_topics_footer'))
treeing/cli/io.py ADDED
@@ -0,0 +1,72 @@
1
+ """
2
+ treeing/cli/io.py
3
+
4
+ Defines CLI standard-stream configuration and safe writing.
5
+ Provides `configure_stdio` (UTF-8 + replace) and the cli_out / cli_warn /
6
+ cli_err helpers, so Windows legacy-codepage consoles do not crash on output.
7
+ """
8
+
9
+ import sys
10
+
11
+ _configured = False
12
+
13
+
14
+ def configure_stdio() -> None:
15
+ """
16
+ At startup, set stdout/stderr to UTF-8 + replace mode.
17
+
18
+ Runs once; later calls return immediately.
19
+ """
20
+ global _configured
21
+ if _configured:
22
+ return
23
+ for stream in (sys.stdout, sys.stderr):
24
+ if hasattr(stream, 'reconfigure'):
25
+ try:
26
+ stream.reconfigure(encoding='utf-8', errors='replace')
27
+ except (OSError, ValueError, AttributeError):
28
+ pass
29
+ _configured = True
30
+
31
+
32
+ def _safe_write(stream, text: str, *, end: str = '\n') -> None:
33
+ """
34
+ Safe write: try a direct write first, then fall back to buffer + replace encoding.
35
+ """
36
+ msg = text + end
37
+ try:
38
+ stream.write(msg)
39
+ stream.flush()
40
+ except UnicodeEncodeError:
41
+ encoding = getattr(stream, 'encoding', None) or 'utf-8'
42
+ if hasattr(stream, 'buffer'):
43
+ stream.buffer.write(msg.encode(encoding, errors='replace'))
44
+ stream.buffer.flush()
45
+ else:
46
+ raise
47
+
48
+
49
+ def cli_out(text: str, *, end: str = '\n') -> None:
50
+ """
51
+ Success summaries and [WARN] warnings go to stdout.
52
+
53
+ Easier for pipes / agents to parse, and friendlier under PowerShell.
54
+ """
55
+ _safe_write(sys.stdout, text, end=end)
56
+
57
+
58
+ def cli_warn(text: str, *, end: str = '\n') -> None:
59
+ """
60
+ Warning prompts go to stdout (same stream as cli_out).
61
+
62
+ MING routes warnings through stdout so agents / scripts get stable
63
+ structured output via a pipe, leaving genuine errors for stderr.
64
+ """
65
+ _safe_write(sys.stdout, text, end=end)
66
+
67
+
68
+ def cli_err(text: str, *, end: str = '\n') -> None:
69
+ """
70
+ Errors and interactive prompts go to stderr ([ERR] and confirm prompts).
71
+ """
72
+ _safe_write(sys.stderr, text, end=end)