Skip to content

Commit e938784

Browse files
nikolay-eclaude
andcommitted
feat: Change default output to stdout instead of file
Breaking change: TreeMapper now outputs to stdout by default instead of creating a file. - Default behavior is now to output YAML to stdout - Use -o/--output-file parameter to write to a file - Support -o - to explicitly output to stdout - Update all tests to specify output file where needed This makes TreeMapper more Unix-friendly and allows for easier piping and redirection. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 3e6d345 commit e938784

File tree

8 files changed

+354
-57
lines changed

8 files changed

+354
-57
lines changed

.treemapperignore

Lines changed: 0 additions & 1 deletion
This file was deleted.

src/treemapper/cli.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
from typing import Optional, Tuple
66

77

8-
# ---> CHANGE: Return types are correct, no change needed here.
9-
def parse_args() -> Tuple[Path, Optional[Path], Path, bool, int]:
8+
# ---> CHANGE: Updated return type to reflect that output_file can now be None.
9+
def parse_args() -> Tuple[Path, Optional[Path], Optional[Path], bool, int]:
1010
"""Parse command line arguments."""
1111
parser = argparse.ArgumentParser(
1212
prog="treemapper",
@@ -18,8 +18,8 @@ def parse_args() -> Tuple[Path, Optional[Path], Path, bool, int]:
1818

1919
parser.add_argument("-i", "--ignore-file", default=None, help="Path to the custom ignore file (optional)")
2020

21-
# ---> CHANGE: Default output is now explicitly relative to the current directory.
22-
parser.add_argument("-o", "--output-file", default="directory_tree.yaml", help="Path to the output YAML file")
21+
# ---> CHANGE: Default output is now stdout (None). Only write to file when explicitly specified.
22+
parser.add_argument("-o", "--output-file", default=None, help="Path to the output YAML file (default: stdout)")
2323

2424
parser.add_argument(
2525
"--no-default-ignores", action="store_true", help="Disable default ignores (.treemapperignore, .gitignore, output file)"
@@ -52,7 +52,10 @@ def parse_args() -> Tuple[Path, Optional[Path], Path, bool, int]:
5252

5353
# ---> CHANGE: Resolve output file relative to the current working directory.
5454
# .resolve() makes the path absolute from CWD if it's relative.
55-
output_file = Path(args.output_file).resolve()
55+
# If no output file is specified or "-" is used, output_file will be None
56+
output_file = None
57+
if args.output_file and args.output_file != "-":
58+
output_file = Path(args.output_file).resolve()
5659

5760
ignore_file_path: Optional[Path] = None
5861
if args.ignore_file:

src/treemapper/writer.py

Lines changed: 47 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22

33
# os is used for access permission checking
44
import os
5+
import sys
56
from pathlib import Path
6-
from typing import Any, Dict
7+
from typing import Any, Dict, Optional, TextIO
78

89

9-
def write_yaml_node(file, node: Dict[str, Any], indent: str = "") -> None:
10+
def write_yaml_node(file: TextIO, node: Dict[str, Any], indent: str = "") -> None:
1011
"""Write a node of the directory tree in YAML format."""
1112
# Escape filename with double quotes and handle any special characters
1213
# This prevents issues with filenames like 'true', 'false', numbers, or names with special chars
@@ -25,40 +26,51 @@ def write_yaml_node(file, node: Dict[str, Any], indent: str = "") -> None:
2526
write_yaml_node(file, child, indent + " ")
2627

2728

28-
def write_tree_to_file(tree: Dict[str, Any], output_file: Path) -> None:
29-
"""Write the complete tree to a YAML file."""
30-
try:
31-
# Create parent directories if they don't exist
32-
output_file.parent.mkdir(parents=True, exist_ok=True)
29+
def write_tree_to_file(tree: Dict[str, Any], output_file: Optional[Path]) -> None:
30+
"""Write the complete tree to a YAML file or stdout."""
3331

34-
# For directories, try an early write test
35-
if output_file.is_dir():
36-
logging.error(f"Unable to write to file '{output_file}': Is a directory")
37-
raise IOError(f"Is a directory: {output_file}")
32+
def write_tree_content(f: TextIO) -> None:
33+
"""Write the tree content to the given file handle."""
34+
# Properly quote the root name as well
35+
name = str(tree["name"]).replace('"', '\\"')
36+
f.write(f'name: "{name}"\n')
37+
f.write(f"type: {tree['type']}\n")
38+
if "children" in tree and tree["children"]:
39+
f.write("children:\n")
40+
for child in tree["children"]:
41+
write_yaml_node(f, child, " ")
3842

39-
# Check write permissions using os.access
40-
if not os.access(output_file.parent, os.W_OK):
41-
logging.error(f"Unable to write to file '{output_file}': Permission denied for directory")
42-
raise IOError(f"Permission denied for directory: {output_file.parent}")
43-
44-
# Test write permissions directly by attempting to open the file
43+
if output_file is None:
44+
# Write to stdout
45+
write_tree_content(sys.stdout)
46+
logging.info("Directory tree written to stdout")
47+
else:
48+
# Write to file
4549
try:
46-
test_handle = output_file.open("w", encoding="utf-8")
47-
test_handle.close()
48-
except (PermissionError, IOError):
49-
logging.error(f"Unable to write to file '{output_file}': Permission denied")
50-
raise IOError(f"Permission denied: {output_file}")
50+
# Create parent directories if they don't exist
51+
output_file.parent.mkdir(parents=True, exist_ok=True)
52+
53+
# For directories, try an early write test
54+
if output_file.is_dir():
55+
logging.error(f"Unable to write to file '{output_file}': Is a directory")
56+
raise IOError(f"Is a directory: {output_file}")
57+
58+
# Check write permissions using os.access
59+
if not os.access(output_file.parent, os.W_OK):
60+
logging.error(f"Unable to write to file '{output_file}': Permission denied for directory")
61+
raise IOError(f"Permission denied for directory: {output_file.parent}")
62+
63+
# Test write permissions directly by attempting to open the file
64+
try:
65+
test_handle = output_file.open("w", encoding="utf-8")
66+
test_handle.close()
67+
except (PermissionError, IOError):
68+
logging.error(f"Unable to write to file '{output_file}': Permission denied")
69+
raise IOError(f"Permission denied: {output_file}")
5170

52-
with output_file.open("w", encoding="utf-8") as f:
53-
# Properly quote the root name as well
54-
name = str(tree["name"]).replace('"', '\\"')
55-
f.write(f'name: "{name}"\n')
56-
f.write(f"type: {tree['type']}\n")
57-
if "children" in tree and tree["children"]:
58-
f.write("children:\n")
59-
for child in tree["children"]:
60-
write_yaml_node(f, child, " ")
61-
logging.info(f"Directory tree saved to {output_file}")
62-
except IOError as e:
63-
logging.error(f"Unable to write to file '{output_file}': {e}")
64-
raise
71+
with output_file.open("w", encoding="utf-8") as f:
72+
write_tree_content(f)
73+
logging.info(f"Directory tree saved to {output_file}")
74+
except IOError as e:
75+
logging.error(f"Unable to write to file '{output_file}': {e}")
76+
raise

tests/test_basic.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def normalize_tree(tree):
3131

3232
def test_basic_mapping(temp_project, run_mapper):
3333
"""Test basic directory mapping with default settings."""
34-
assert run_mapper(["."])
34+
assert run_mapper([".", "-o", "directory_tree.yaml"])
3535
output_file = temp_project / "directory_tree.yaml"
3636
assert output_file.exists()
3737
result = load_yaml(output_file)
@@ -49,7 +49,7 @@ def test_basic_mapping(temp_project, run_mapper):
4949

5050
def test_directory_content(temp_project, run_mapper):
5151
"""Test directory structure and content preservation."""
52-
assert run_mapper(["."])
52+
assert run_mapper([".", "-o", "directory_tree.yaml"])
5353
result = load_yaml(temp_project / "directory_tree.yaml")
5454
src_dir = find_node_by_path(result, ["src"])
5555
assert src_dir is not None and src_dir["type"] == "directory"
@@ -93,7 +93,7 @@ def test_file_content_encoding(temp_project, run_mapper):
9393
(temp_project / "multiline.txt").write_text(multiline_content_orig)
9494
(temp_project / "empty.txt").write_text(empty_content_orig)
9595

96-
assert run_mapper(["."])
96+
assert run_mapper([".", "-o", "directory_tree.yaml"])
9797
result = load_yaml(temp_project / "directory_tree.yaml")
9898

9999
ascii_node = find_node_by_path(result, ["ascii.txt"])
@@ -116,7 +116,7 @@ def test_nested_structures(temp_project, run_mapper):
116116
contents[i] = content_str
117117
(current / f"file{i}.txt").write_text(content_str)
118118

119-
assert run_mapper(["."])
119+
assert run_mapper([".", "-o", "directory_tree.yaml"])
120120
result = load_yaml(temp_project / "directory_tree.yaml")
121121

122122
current_node = result

tests/test_default_ignores.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def test_default_python_ignores(temp_project, run_mapper):
3737
(temp_project / "actual_module.py").touch()
3838

3939
# Run TreeMapper and check results
40-
assert run_mapper(["."])
40+
assert run_mapper([".", "-o", "directory_tree.yaml"])
4141
result = load_yaml(temp_project / "directory_tree.yaml")
4242
all_files = get_all_files_in_tree(result)
4343

@@ -76,7 +76,7 @@ def test_git_directory_ignored(temp_project, run_mapper):
7676
(temp_project / "README.md").touch()
7777

7878
# Run TreeMapper and check results
79-
assert run_mapper(["."])
79+
assert run_mapper([".", "-o", "directory_tree.yaml"])
8080
result = load_yaml(temp_project / "directory_tree.yaml")
8181
all_files = get_all_files_in_tree(result)
8282

@@ -101,7 +101,7 @@ def test_default_verbosity(temp_project, run_mapper, capfd):
101101
capfd.readouterr()
102102

103103
# Run with default verbosity (ERROR)
104-
assert run_mapper(["."])
104+
assert run_mapper([".", "-o", "directory_tree.yaml"])
105105
out, err = capfd.readouterr()
106106

107107
# At ERROR level, there should be no INFO messages

tests/test_ignore.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def test_custom_ignore(temp_project, run_mapper):
2121
.gitignore
2222
"""
2323
)
24-
assert run_mapper([".", "-i", str(ignore_file)])
24+
assert run_mapper([".", "-i", str(ignore_file), "-o", "directory_tree.yaml"])
2525
result = load_yaml(temp_project / "directory_tree.yaml")
2626
all_files = get_all_files_in_tree(result)
2727
assert not any(isinstance(f, str) and f.endswith(".py") for f in all_files)
@@ -38,7 +38,7 @@ def test_gitignore_patterns(temp_project, run_mapper):
3838
(temp_project / "__pycache__" / "cachefile").touch()
3939
(temp_project / "src" / "local_only.py").touch()
4040
(temp_project / "src" / "allowed.py").touch()
41-
assert run_mapper(["."])
41+
assert run_mapper([".", "-o", "directory_tree.yaml"])
4242
result = load_yaml(temp_project / "directory_tree.yaml")
4343
all_files = get_all_files_in_tree(result)
4444
assert "test.pyc" not in all_files
@@ -77,7 +77,7 @@ def test_symlinks_and_special_files(temp_project, run_mapper):
7777
can_symlink = False
7878

7979
(temp_project / ".treemapperignore").write_text(".*\n!.gitignore\n")
80-
assert run_mapper(["."])
80+
assert run_mapper([".", "-o", "directory_tree.yaml"])
8181
result = load_yaml(temp_project / "directory_tree.yaml")
8282
all_files = get_all_files_in_tree(result)
8383
assert ".hidden_dir" not in all_files
@@ -236,7 +236,7 @@ def test_unreadable_ignore_file(temp_project, run_mapper, set_perms, caplog):
236236
set_perms(ignore_file, 0o000)
237237

238238
with caplog.at_level(logging.WARNING):
239-
assert run_mapper(["."])
239+
assert run_mapper([".", "-o", "directory_tree.yaml"])
240240

241241
assert any(
242242
"Could not read ignore file" in rec.message and ignore_file.name in rec.message
@@ -255,7 +255,7 @@ def test_bad_encoding_ignore_file(temp_project, run_mapper, caplog):
255255
pytest.skip("CP1251 codec not found")
256256

257257
with caplog.at_level(logging.WARNING):
258-
assert run_mapper(["."])
258+
assert run_mapper([".", "-o", "directory_tree.yaml"])
259259

260260
assert any(
261261
"Could not decode ignore file" in rec.message and ignore_file.name in rec.message and "UTF-8" in rec.message
@@ -267,7 +267,7 @@ def test_bad_encoding_ignore_file(temp_project, run_mapper, caplog):
267267

268268
(temp_project / "папка_игнор").mkdir()
269269

270-
assert run_mapper(["."])
270+
assert run_mapper([".", "-o", "directory_tree.yaml"])
271271
result = load_yaml(temp_project / "directory_tree.yaml")
272272
all_files = get_all_files_in_tree(result)
273273
assert "папка_игнор" in all_files

tests/test_ignore_advanced.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ def test_ignore_precedence_subdir_over_root(temp_project, run_mapper):
1515
(temp_project / "subdir" / "ignore.txt").touch()
1616
(temp_project / "subdir" / "allow.txt").touch()
1717

18-
assert run_mapper(["."])
18+
assert run_mapper([".", "-o", "directory_tree.yaml"])
1919
result = load_yaml(temp_project / "directory_tree.yaml")
2020
all_files = get_all_files_in_tree(result)
2121

@@ -32,7 +32,7 @@ def test_ignore_precedence_treemapper_over_git(temp_project, run_mapper):
3232
(temp_project / "file.log").touch()
3333
(temp_project / "file.txt").touch()
3434

35-
assert run_mapper(["."])
35+
assert run_mapper([".", "-o", "directory_tree.yaml"])
3636
result = load_yaml(temp_project / "directory_tree.yaml")
3737
all_files = get_all_files_in_tree(result)
3838

@@ -53,7 +53,7 @@ def test_ignore_precedence_custom_over_defaults(temp_project, run_mapper):
5353
(temp_project / "file.tmp").touch()
5454
(temp_project / "file.txt").touch()
5555

56-
assert run_mapper([".", "-i", str(custom_ignore)])
56+
assert run_mapper([".", "-i", str(custom_ignore), "-o", "directory_tree.yaml"])
5757
result = load_yaml(temp_project / "directory_tree.yaml")
5858
all_files = get_all_files_in_tree(result)
5959

0 commit comments

Comments
 (0)