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/cli/main.py ADDED
@@ -0,0 +1,427 @@
1
+ """
2
+ treeing/cli/main.py
3
+
4
+ Defines the CLI main entry point and command dispatch.
5
+ Handles argument parsing, path validation, the parse/generate flow, warning
6
+ output, JSON results and exit codes.
7
+ """
8
+
9
+ import os
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from ..config import get_string
14
+ from ..core.generator import create_from_tree, iter_nodes
15
+ from ..core.parser import build_tree
16
+ from ..core.preview import render_text_tree
17
+ from ..path_checks import verify_output_is_directory, verify_output_writable
18
+ from .confirm import gate_before_write
19
+ from .help_text import build_parser, dispatch_help_argv
20
+ from .io import cli_err, cli_out, cli_warn, configure_stdio
21
+ from .report import (
22
+ DEFAULT_WARN_LIMIT,
23
+ EXIT_FAILURE,
24
+ build_json_result,
25
+ emit_json,
26
+ resolve_exit_code,
27
+ write_warnings_file,
28
+ )
29
+
30
+ _FORMAT_TEXT = 'text'
31
+ _FORMAT_TREE = 'tree'
32
+
33
+
34
+ def _ensure_utf8_console() -> None:
35
+ """
36
+ Switch the Windows console (default legacy codepage) to UTF-8 to avoid garbled output.
37
+
38
+ Best effect under cmd and the PyInstaller exe; under PowerShell prefer the
39
+ scripts/treeing-cli.ps1 launcher, since the host encoding is not controlled
40
+ by this process.
41
+ """
42
+ if sys.platform != 'win32':
43
+ return
44
+
45
+ # Force the Python standard-stream environment variable.
46
+ if 'PYTHONIOENCODING' not in os.environ:
47
+ os.environ['PYTHONIOENCODING'] = 'utf-8'
48
+
49
+ # Set the current process console code page to UTF-8.
50
+ try:
51
+ import ctypes
52
+ ctypes.windll.kernel32.SetConsoleCP(65001)
53
+ ctypes.windll.kernel32.SetConsoleOutputCP(65001)
54
+ except Exception: # nosec B110 - safe fallback when the console code page switch fails
55
+ pass
56
+
57
+
58
+ def _print_warning_sections(
59
+ sections: list[tuple[str, list[str]]],
60
+ *,
61
+ limit: int | None,
62
+ quiet: bool,
63
+ ) -> None:
64
+ """
65
+ Print warning lists by stage (parse / generate).
66
+
67
+ With limit=None nothing is truncated; otherwise truncation is followed by
68
+ an "N more" line.
69
+ """
70
+ if quiet:
71
+ return
72
+
73
+ non_empty = [(title_key, ws) for title_key, ws in sections if ws]
74
+ if not non_empty:
75
+ return
76
+
77
+ tagged: list[tuple[str, str]] = []
78
+ for title_key, ws in non_empty:
79
+ for w in ws:
80
+ tagged.append((title_key, w))
81
+
82
+ total = len(tagged)
83
+ effective_limit = total if limit is None else limit
84
+ shown = tagged[:effective_limit]
85
+ if limit is not None and total > len(shown):
86
+ cli_warn(get_string("cli_warning_header_truncated", count=total, shown=len(shown)))
87
+ else:
88
+ cli_warn(get_string("cli_warning_header", count=total))
89
+
90
+ current_section: str | None = None
91
+ section_totals = {tk: len(ws) for tk, ws in non_empty}
92
+ for title_key, warning in shown:
93
+ if title_key != current_section:
94
+ cli_warn(get_string(
95
+ "cli_warning_section",
96
+ title=get_string(title_key),
97
+ count=section_totals[title_key],
98
+ ))
99
+ current_section = title_key
100
+ cli_warn(get_string("cli_warning_item", warning=warning))
101
+
102
+ if limit is not None and total > len(shown):
103
+ cli_warn(get_string("cli_warning_more", extra=total - len(shown)))
104
+
105
+
106
+ def _warn_display_limit(args) -> int | None:
107
+ """
108
+ Decide the terminal warning display cap from the arguments.
109
+
110
+ --no-warn-limit means no cap; --warn-limit=0 also means no cap; otherwise
111
+ the default DEFAULT_WARN_LIMIT is used.
112
+ """
113
+ if args.no_warn_limit:
114
+ return None
115
+ if args.warn_limit is not None:
116
+ return args.warn_limit if args.warn_limit > 0 else None
117
+ return DEFAULT_WARN_LIMIT
118
+
119
+
120
+ def _maybe_write_warnings_file(
121
+ path: str | None,
122
+ parse_warnings: list[str],
123
+ gen_warnings: list[str],
124
+ ) -> str | None:
125
+ """
126
+ When the user passes --warnings-file, write the warnings there.
127
+
128
+ Returns None on success; returns an error message string on write failure.
129
+ """
130
+ if not path:
131
+ return None
132
+ try:
133
+ write_warnings_file(Path(path), parse_warnings, gen_warnings)
134
+ except OSError as e:
135
+ return get_string('cli_err_warnings_file_write', path=path, error=e)
136
+ return None
137
+
138
+
139
+ def _emit_tree_preview(tree: list[dict], *, allow_nested: bool) -> list[str]:
140
+ """Render the parsed tree into text-tree lines for --format tree."""
141
+ return render_text_tree(tree, allow_nested=allow_nested)
142
+
143
+
144
+ def _print_tree_preview(lines: list[str]) -> None:
145
+ """Print text-tree lines to stdout, one per line."""
146
+ for line in lines:
147
+ cli_out(line)
148
+
149
+
150
+ def _emit_failure(
151
+ *,
152
+ args,
153
+ error: str,
154
+ parse_warnings: list[str] | None = None,
155
+ gen_warnings: list[str] | None = None,
156
+ dry_run: bool = False,
157
+ node_count: int = 0,
158
+ tree_preview: list[str] | None = None,
159
+ implicit_output_warning: str | None = None,
160
+ ) -> int:
161
+ """
162
+ Emit a failure result uniformly.
163
+
164
+ Output format follows --json / --quiet; with --warnings-file the warnings
165
+ gathered so far are still written before reporting the failure.
166
+ """
167
+ parse_warnings = parse_warnings or []
168
+ gen_warnings = gen_warnings or []
169
+ warn_file_err = _maybe_write_warnings_file(args.warnings_file, parse_warnings, gen_warnings)
170
+ exit_code = EXIT_FAILURE
171
+
172
+ if args.json:
173
+ emit_json(build_json_result(
174
+ ok=False,
175
+ dry_run=dry_run,
176
+ node_count=node_count,
177
+ output=args.output,
178
+ parse_warnings=parse_warnings,
179
+ gen_warnings=gen_warnings,
180
+ error=error,
181
+ exit_code=exit_code,
182
+ tree_preview=tree_preview,
183
+ implicit_output_warning=implicit_output_warning,
184
+ ))
185
+ else:
186
+ if not args.quiet:
187
+ _print_warning_sections([
188
+ ('cli_warning_section_parse', parse_warnings),
189
+ ('cli_warning_section_generate', gen_warnings),
190
+ ], limit=_warn_display_limit(args), quiet=False)
191
+ cli_err(error)
192
+ if warn_file_err and not args.quiet:
193
+ cli_warn(warn_file_err)
194
+
195
+ return exit_code
196
+
197
+
198
+ def _emit_success(
199
+ *,
200
+ args,
201
+ tree: list[dict],
202
+ parse_warnings: list[str],
203
+ gen_warnings: list[str],
204
+ node_count: int,
205
+ confirm_mode: str | None = None,
206
+ implicit_output_warning: str | None = None,
207
+ ) -> int:
208
+ """
209
+ Emit a success result uniformly.
210
+
211
+ Handles the warnings file, tree preview, success summary, JSON output and
212
+ the exit code 2 from --warn-exit-code.
213
+ """
214
+ warn_file_err = _maybe_write_warnings_file(args.warnings_file, parse_warnings, gen_warnings)
215
+ has_warnings = bool(parse_warnings or gen_warnings)
216
+ exit_code = resolve_exit_code(
217
+ ok=True,
218
+ has_warnings=has_warnings,
219
+ warn_exit_code=args.warn_exit_code,
220
+ )
221
+ tree_preview = (
222
+ _emit_tree_preview(tree, allow_nested=args.allow_nested_names)
223
+ if args.format == _FORMAT_TREE
224
+ else None
225
+ )
226
+
227
+ if args.json:
228
+ emit_json(build_json_result(
229
+ ok=True,
230
+ dry_run=args.dry_run,
231
+ node_count=node_count,
232
+ output=args.output,
233
+ parse_warnings=parse_warnings,
234
+ gen_warnings=gen_warnings,
235
+ exit_code=exit_code,
236
+ tree_preview=tree_preview,
237
+ confirm_mode=confirm_mode,
238
+ implicit_output_warning=implicit_output_warning,
239
+ ))
240
+ else:
241
+ _print_warning_sections([
242
+ ('cli_warning_section_parse', parse_warnings),
243
+ ('cli_warning_section_generate', gen_warnings),
244
+ ], limit=_warn_display_limit(args), quiet=args.quiet)
245
+ if tree_preview is not None:
246
+ _print_tree_preview(tree_preview)
247
+ if not args.quiet and args.format == _FORMAT_TEXT:
248
+ if args.dry_run:
249
+ cli_out(get_string("cli_dry_run_msg", count=node_count))
250
+ else:
251
+ cli_out(get_string("cli_generate_success", count=node_count, path=args.output))
252
+ if warn_file_err and not args.quiet:
253
+ cli_warn(warn_file_err)
254
+
255
+ return exit_code
256
+
257
+
258
+ def _resolve_output(args) -> str:
259
+ """
260
+ Resolve the final output directory.
261
+
262
+ Priority: -o > the directory remembered via --use-settings > the current
263
+ working directory. MING folds the GUI's recent directory in here so the
264
+ CLI and GUI share one set of habits.
265
+ """
266
+ if args.output is not None:
267
+ return args.output
268
+ if args.use_settings:
269
+ from ..gui.settings import get_last_generate_dir
270
+ return get_last_generate_dir() or '.'
271
+ return '.'
272
+
273
+
274
+ def _implicit_output_warning_message(*, output_explicit: bool, args) -> str | None:
275
+ """
276
+ Return the warning text when no explicit -o is given and the current working directory will be written to.
277
+
278
+ Covers the --use-settings fallback to the current directory; dry-run does
279
+ not warn.
280
+ """
281
+ if args.dry_run or output_explicit:
282
+ return None
283
+ try:
284
+ if Path(args.output).resolve() != Path.cwd().resolve():
285
+ return None
286
+ except OSError:
287
+ return None
288
+ return get_string('cli_warn_implicit_output', path=args.output)
289
+
290
+
291
+ def cli_main() -> int:
292
+ """
293
+ CLI main entry point.
294
+
295
+ Flow: handle help / about -> parse arguments -> read input -> parse tree
296
+ -> validate path -> confirm -> generate filesystem -> emit result / JSON.
297
+
298
+ MING routes every error path through _emit_failure so the output structure
299
+ stays consistent across JSON and non-JSON modes.
300
+ """
301
+ _ensure_utf8_console()
302
+ configure_stdio()
303
+
304
+ help_code = dispatch_help_argv(sys.argv)
305
+ if help_code is not None:
306
+ return help_code
307
+
308
+ parser = build_parser()
309
+ args = parser.parse_args()
310
+
311
+ if args.strict:
312
+ args.no_fix = True
313
+ args.fail_on_conflict = True
314
+
315
+ output_explicit = args.output is not None
316
+ args.output = _resolve_output(args)
317
+ fail_on_conflict = args.fail_on_conflict or args.fail_on_duplicate
318
+
319
+ output_dir_err = verify_output_is_directory(args.output)
320
+ if output_dir_err:
321
+ return _emit_failure(args=args, error=output_dir_err)
322
+
323
+ if args.paste and args.input:
324
+ parser.error(get_string("cli_error_input_conflict"))
325
+
326
+ if args.confirm and args.json:
327
+ parser.error(get_string("cli_error_confirm_json"))
328
+
329
+ if args.paste:
330
+ if not args.quiet and not args.json:
331
+ cli_err(get_string("cli_prompt_paste"))
332
+ try:
333
+ text = sys.stdin.buffer.read().decode(args.encoding)
334
+ except UnicodeDecodeError as e:
335
+ msg = get_string("cli_reading_stdin_error", error=e)
336
+ return _emit_failure(args=args, error=msg)
337
+ elif args.input:
338
+ try:
339
+ with open(args.input, encoding=args.encoding) as f:
340
+ text = f.read()
341
+ except Exception as e:
342
+ msg = get_string("cli_reading_error", error=e)
343
+ return _emit_failure(args=args, error=msg)
344
+ else:
345
+ parser.print_help()
346
+ return 0
347
+
348
+ lines = text.splitlines()
349
+ tree, parse_warnings = build_tree(
350
+ lines,
351
+ auto_fix=not args.no_fix,
352
+ indent_unit=args.indent_unit,
353
+ strict_dirs=args.strict_dirs,
354
+ )
355
+
356
+ if not tree:
357
+ return _emit_failure(
358
+ args=args,
359
+ error=get_string("cli_empty_tree"),
360
+ parse_warnings=parse_warnings,
361
+ )
362
+
363
+ implicit_output_warning = _implicit_output_warning_message(
364
+ output_explicit=output_explicit, args=args,
365
+ )
366
+
367
+ if args.check_writable:
368
+ writable_err = verify_output_writable(args.output)
369
+ if writable_err:
370
+ return _emit_failure(
371
+ args=args,
372
+ error=writable_err,
373
+ parse_warnings=parse_warnings,
374
+ implicit_output_warning=implicit_output_warning,
375
+ )
376
+
377
+ node_count = sum(1 for _ in iter_nodes(tree))
378
+ warn_count = len(parse_warnings)
379
+
380
+ if implicit_output_warning and not (args.json and args.quiet):
381
+ cli_warn(implicit_output_warning)
382
+
383
+ early_exit, confirm_mode = gate_before_write(
384
+ args,
385
+ path=args.output,
386
+ node_count=node_count,
387
+ fail_on_conflict=fail_on_conflict,
388
+ warn_count=warn_count,
389
+ )
390
+ if early_exit is not None:
391
+ return early_exit
392
+
393
+ gen_warnings: list[str] = []
394
+ try:
395
+ create_from_tree(
396
+ tree,
397
+ Path(args.output),
398
+ dry_run=args.dry_run,
399
+ warnings=gen_warnings,
400
+ allow_nested_names=args.allow_nested_names,
401
+ fail_on_conflict=fail_on_conflict,
402
+ rollback_on_error=args.rollback_on_error,
403
+ )
404
+ return _emit_success(
405
+ args=args,
406
+ tree=tree,
407
+ parse_warnings=parse_warnings,
408
+ gen_warnings=gen_warnings,
409
+ node_count=node_count,
410
+ confirm_mode=confirm_mode,
411
+ implicit_output_warning=implicit_output_warning,
412
+ )
413
+ except Exception as e:
414
+ return _emit_failure(
415
+ args=args,
416
+ error=get_string("cli_generate_failed", error=e),
417
+ parse_warnings=parse_warnings,
418
+ gen_warnings=gen_warnings,
419
+ dry_run=args.dry_run,
420
+ node_count=sum(1 for _ in iter_nodes(tree)),
421
+ tree_preview=(
422
+ _emit_tree_preview(tree, allow_nested=args.allow_nested_names)
423
+ if args.format == _FORMAT_TREE
424
+ else None
425
+ ),
426
+ implicit_output_warning=implicit_output_warning,
427
+ )
treeing/cli/report.py ADDED
@@ -0,0 +1,104 @@
1
+ """
2
+ treeing/cli/report.py
3
+
4
+ Defines the CLI result-reporting logic: JSON output, warnings-file writing,
5
+ and exit-code resolution. Provides helpers such as `build_json_result`,
6
+ `emit_json` and `resolve_exit_code`.
7
+ """
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+ from ..config import get_string
13
+ from .io import cli_out
14
+
15
+ DEFAULT_WARN_LIMIT = 10
16
+ EXIT_SUCCESS = 0
17
+ EXIT_FAILURE = 1
18
+ EXIT_WARNINGS = 2
19
+
20
+
21
+ def resolve_exit_code(*, ok: bool, has_warnings: bool, warn_exit_code: bool) -> int:
22
+ """
23
+ Decide the exit code from the run result and warnings.
24
+
25
+ With warn_exit_code set, a successful run with warnings returns 2
26
+ (EXIT_WARNINGS); a failure always returns 1.
27
+ """
28
+ if not ok:
29
+ return EXIT_FAILURE
30
+ if has_warnings and warn_exit_code:
31
+ return EXIT_WARNINGS
32
+ return EXIT_SUCCESS
33
+
34
+
35
+ def write_warnings_file(
36
+ path: Path,
37
+ parse_warnings: list[str],
38
+ gen_warnings: list[str],
39
+ ) -> None:
40
+ """
41
+ Write parse and generation warnings to the given file, prefixed by stage.
42
+
43
+ Edge cases: write failures are caught by the caller; an empty list still
44
+ writes a trailing newline.
45
+ """
46
+ lines: list[str] = []
47
+ parse_title = get_string('cli_warning_section_parse')
48
+ gen_title = get_string('cli_warning_section_generate')
49
+ for w in parse_warnings:
50
+ lines.append(f'[{parse_title}] {w}')
51
+ for w in gen_warnings:
52
+ lines.append(f'[{gen_title}] {w}')
53
+ path.write_text('\n'.join(lines) + ('\n' if lines else ''), encoding='utf-8')
54
+
55
+
56
+ def build_json_result(
57
+ *,
58
+ ok: bool,
59
+ dry_run: bool = False,
60
+ node_count: int = 0,
61
+ output: str = '',
62
+ parse_warnings: list[str],
63
+ gen_warnings: list[str],
64
+ error: str | None = None,
65
+ exit_code: int = EXIT_SUCCESS,
66
+ tree_preview: list[str] | None = None,
67
+ confirm_mode: str | None = None,
68
+ implicit_output_warning: str | None = None,
69
+ ) -> dict:
70
+ """
71
+ Assemble the JSON result dictionary for --json output.
72
+
73
+ Optional fields are only included when not None.
74
+ """
75
+ result: dict = {
76
+ 'ok': ok,
77
+ 'dry_run': dry_run,
78
+ 'node_count': node_count,
79
+ 'output': output,
80
+ 'parse_warnings': parse_warnings,
81
+ 'generate_warnings': gen_warnings,
82
+ 'exit_code': exit_code,
83
+ }
84
+ if error is not None:
85
+ result['error'] = error
86
+ if tree_preview is not None:
87
+ result['tree_preview'] = tree_preview
88
+ if confirm_mode is not None:
89
+ result['confirmed'] = True
90
+ result['confirm_mode'] = confirm_mode
91
+ if implicit_output_warning is not None:
92
+ result['implicit_output_warning'] = True
93
+ result['implicit_output_warning_message'] = implicit_output_warning
94
+ return result
95
+
96
+
97
+ def emit_json(data: dict) -> None:
98
+ """
99
+ Print the result dictionary as a single-line JSON object to stdout.
100
+
101
+ Uses ensure_ascii=False so non-ASCII text stays readable in the JSON
102
+ rather than being escaped to \\uXXXX.
103
+ """
104
+ cli_out(json.dumps(data, ensure_ascii=False))
treeing/cli_entry.py ADDED
@@ -0,0 +1,16 @@
1
+ """treeing/cli_entry.py
2
+
3
+ PyInstaller CLI entry point.
4
+ Does not import tkinter, which suits a minimal build; calls cli_main directly and handles ConfigError.
5
+ """
6
+ import sys
7
+
8
+ from treeing.cli.main import cli_main
9
+ from treeing.config import ConfigError
10
+
11
+ if __name__ == '__main__':
12
+ try:
13
+ sys.exit(cli_main())
14
+ except ConfigError as e:
15
+ print(str(e), file=sys.stderr)
16
+ sys.exit(1)