Skip to content

Commit 31358cc

Browse files
committed
feat: modernize tests, add hierarchical ignores, expand default patterns
1 parent ba24c1b commit 31358cc

15 files changed

+1019
-981
lines changed

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ dev = [
4242
# Testing
4343
"pytest>=7.0,<9.0",
4444
"pytest-cov>=3.0,<8.0",
45+
"pytest-xdist>=3.0,<4.0",
4546
"hypothesis>=6.0,<7.0",
4647
"mutmut>=2.0,<4.0",
4748
"coverage>=7.0,<8.0",
@@ -77,3 +78,4 @@ where = ["src"]
7778
testpaths = ["tests"]
7879
pythonpath = ["src"]
7980
norecursedirs = ["mutants", ".git", ".mypy_cache", ".pytest_cache", "__pycache__"]
81+
addopts = "-n auto --dist loadfile"

src/treemapper/cli.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,31 @@ class ParsedArgs:
2020
max_file_bytes: Optional[int]
2121

2222

23+
DEFAULT_IGNORES_HELP = """
24+
Default ignored patterns (use --no-default-ignores to include all):
25+
.git/, .svn/, .hg/ Version control directories
26+
__pycache__/, *.py[cod], *.so, venv/, .venv/, .tox/, .nox/ Python
27+
node_modules/, .npm/ JavaScript/Node
28+
target/, .gradle/ Java/Maven/Gradle
29+
bin/, obj/ .NET
30+
vendor/ Go/PHP
31+
dist/, build/, out/ Generic build output
32+
.*_cache/ All cache dirs (.pytest_cache, .mypy_cache, etc.)
33+
.idea/, .vscode/ IDE configurations
34+
.DS_Store, Thumbs.db OS-specific files
35+
36+
Ignore files (hierarchical, like git):
37+
.gitignore Standard git ignore patterns
38+
.treemapperignore TreeMapper-specific patterns
39+
"""
40+
41+
2342
def parse_args() -> ParsedArgs:
2443
parser = argparse.ArgumentParser(
2544
prog="treemapper",
2645
description="Generate a structured representation of a directory tree (YAML, JSON, or text).",
27-
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
46+
epilog=DEFAULT_IGNORES_HELP,
47+
formatter_class=argparse.RawDescriptionHelpFormatter,
2848
)
2949

3050
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")

src/treemapper/ignore.py

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,19 +45,19 @@ def _get_output_file_pattern(output_file: Optional[Path], root_dir: Path) -> Opt
4545
return None
4646

4747

48-
def _aggregate_gitignore_patterns(root: Path) -> List[str]:
48+
def _aggregate_ignore_patterns(root: Path, ignore_filename: str) -> List[str]:
4949
out: List[str] = []
5050
for dirpath, dirnames, filenames in os.walk(root, topdown=True):
5151
dirnames.sort()
5252
filenames.sort()
5353

54-
if ".gitignore" not in filenames:
54+
if ignore_filename not in filenames:
5555
continue
5656

57-
gitdir = Path(dirpath)
58-
rel = "" if gitdir == root else gitdir.relative_to(root).as_posix()
57+
ignore_dir = Path(dirpath)
58+
rel = "" if ignore_dir == root else ignore_dir.relative_to(root).as_posix()
5959

60-
for line in read_ignore_file(gitdir / ".gitignore"):
60+
for line in read_ignore_file(ignore_dir / ignore_filename):
6161
neg = line.startswith("!")
6262
pat = line[1:] if neg else line
6363

@@ -68,27 +68,48 @@ def _aggregate_gitignore_patterns(root: Path) -> List[str]:
6868

6969
out.append(("!" + full) if neg else full)
7070

71-
logging.debug(f"Aggregated {len(out)} gitignore patterns from {root}")
71+
logging.debug(f"Aggregated {len(out)} {ignore_filename} patterns from {root}")
7272
return out
7373

7474

7575
DEFAULT_IGNORE_PATTERNS = [
76+
# Version control
77+
"**/.git/",
78+
"**/.svn/",
79+
"**/.hg/",
80+
# Python
7681
"**/__pycache__/",
7782
"**/*.py[cod]",
7883
"**/*.so",
79-
"**/.pytest_cache/",
8084
"**/.coverage",
81-
"**/.mypy_cache/",
8285
"**/*.egg-info/",
8386
"**/.eggs/",
84-
"**/.git/",
85-
"**/node_modules/",
8687
"**/venv/",
8788
"**/.venv/",
8889
"**/.tox/",
8990
"**/.nox/",
91+
# JavaScript/Node
92+
"**/node_modules/",
93+
"**/.npm/",
94+
# Java/JVM
95+
"**/target/",
96+
"**/.gradle/",
97+
# .NET
98+
"**/bin/",
99+
"**/obj/",
100+
# Go
101+
"**/vendor/",
102+
# Generic build/cache
90103
"**/dist/",
91104
"**/build/",
105+
"**/out/",
106+
"**/.*_cache/",
107+
# IDE
108+
"**/.idea/",
109+
"**/.vscode/",
110+
# OS files
111+
"**/.DS_Store",
112+
"**/Thumbs.db",
92113
]
93114

94115

@@ -102,8 +123,8 @@ def get_ignore_specs(
102123

103124
if not no_default_ignores:
104125
patterns.extend(DEFAULT_IGNORE_PATTERNS)
105-
patterns.extend(read_ignore_file(root_dir / ".treemapperignore"))
106-
patterns.extend(_aggregate_gitignore_patterns(root_dir))
126+
patterns.extend(_aggregate_ignore_patterns(root_dir, ".treemapperignore"))
127+
patterns.extend(_aggregate_ignore_patterns(root_dir, ".gitignore"))
107128

108129
if custom_ignore_file:
109130
patterns.extend(read_ignore_file(custom_ignore_file))

src/treemapper/writer.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ class LiteralStr(str):
1212
pass
1313

1414

15+
class QuotedStr(str):
16+
pass
17+
18+
1519
_yaml_representer_registered = False
1620

1721

@@ -20,18 +24,26 @@ def _ensure_yaml_representer() -> None:
2024
if _yaml_representer_registered:
2125
return
2226

23-
def representer(dumper: yaml.SafeDumper, data: LiteralStr) -> yaml.ScalarNode:
27+
def literal_representer(dumper: yaml.SafeDumper, data: LiteralStr) -> yaml.ScalarNode:
2428
style = "|" if data and not data.endswith("\n") else "|+"
2529
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style=style)
2630

27-
yaml.add_representer(LiteralStr, representer, Dumper=yaml.SafeDumper)
31+
def quoted_representer(dumper: yaml.SafeDumper, data: QuotedStr) -> yaml.ScalarNode:
32+
# Use double-quote style to properly escape NEL (U+0085) and other special chars
33+
return dumper.represent_scalar("tag:yaml.org,2002:str", str(data), style='"')
34+
35+
yaml.add_representer(LiteralStr, literal_representer, Dumper=yaml.SafeDumper)
36+
yaml.add_representer(QuotedStr, quoted_representer, Dumper=yaml.SafeDumper)
2837
_yaml_representer_registered = True
2938

3039

3140
def _prepare_tree_for_yaml(node: Dict[str, Any]) -> Dict[str, Any]:
3241
result: Dict[str, Any] = {}
3342
for key, value in node.items():
34-
if key == "content" and isinstance(value, str) and "\n" in value:
43+
if isinstance(value, str) and "\x85" in value:
44+
# NEL (U+0085) must be quoted to preserve roundtrip - check FIRST
45+
result[key] = QuotedStr(value)
46+
elif key == "content" and isinstance(value, str) and "\n" in value:
3547
result[key] = LiteralStr(value)
3648
elif key == "children" and isinstance(value, list):
3749
result[key] = [_prepare_tree_for_yaml(child) for child in value]

tests/conftest.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,99 @@ def _set_perms(path: Path, perms: int):
148148

149149

150150
# ---> КОНЕЦ: Перенесенная фикстура set_perms <---
151+
152+
153+
# --- New fixtures for test modernization ---
154+
155+
156+
@pytest.fixture
157+
def project_builder(tmp_path):
158+
"""Builder pattern for creating test project structures."""
159+
from typing import List
160+
161+
class ProjectBuilder:
162+
def __init__(self, base_path: Path):
163+
self.root = base_path / "treemapper_test_project"
164+
self.root.mkdir()
165+
166+
def add_file(self, path: str, content: str = "") -> Path:
167+
file_path = self.root / path
168+
file_path.parent.mkdir(parents=True, exist_ok=True)
169+
file_path.write_text(content, encoding="utf-8")
170+
return file_path
171+
172+
def add_binary(self, path: str, content: bytes = b"\x00\x01\x02") -> Path:
173+
file_path = self.root / path
174+
file_path.parent.mkdir(parents=True, exist_ok=True)
175+
file_path.write_bytes(content)
176+
return file_path
177+
178+
def add_dir(self, path: str) -> Path:
179+
dir_path = self.root / path
180+
dir_path.mkdir(parents=True, exist_ok=True)
181+
return dir_path
182+
183+
def add_gitignore(self, patterns: List[str], subdir: str = "") -> Path:
184+
path = self.root / subdir / ".gitignore" if subdir else self.root / ".gitignore"
185+
path.parent.mkdir(parents=True, exist_ok=True)
186+
path.write_text("\n".join(patterns) + "\n", encoding="utf-8")
187+
return path
188+
189+
def add_treemapperignore(self, patterns: List[str]) -> Path:
190+
path = self.root / ".treemapperignore"
191+
path.write_text("\n".join(patterns) + "\n", encoding="utf-8")
192+
return path
193+
194+
def create_nested(self, depth: int, files_per_level: int = 1) -> None:
195+
current = self.root
196+
for i in range(depth):
197+
current = current / f"level{i}"
198+
current.mkdir(exist_ok=True)
199+
for j in range(files_per_level):
200+
(current / f"file{j}.txt").write_text(f"Content {i}-{j}")
201+
202+
return ProjectBuilder(tmp_path)
203+
204+
205+
@pytest.fixture
206+
def cli_runner(temp_project):
207+
"""Simplified CLI runner with automatic success assertion."""
208+
209+
def _run(args, cwd=None, expect_success=True):
210+
result = run_treemapper_subprocess(args, cwd=cwd or temp_project)
211+
if expect_success:
212+
assert result.returncode == 0, f"CLI failed with stderr: {result.stderr}"
213+
return result
214+
215+
return _run
216+
217+
218+
@pytest.fixture
219+
def run_and_verify(run_mapper, temp_project):
220+
"""Run mapper and verify tree structure."""
221+
from tests.utils import get_all_files_in_tree, load_yaml
222+
223+
def _run(
224+
args=None,
225+
expected_files=None,
226+
excluded_files=None,
227+
output_name="output.yaml",
228+
):
229+
output_path = temp_project / output_name
230+
full_args = ["."] + (args or []) + ["-o", str(output_path)]
231+
success = run_mapper(full_args)
232+
assert success, f"Mapper failed with args: {full_args}"
233+
234+
result = load_yaml(output_path)
235+
all_files = get_all_files_in_tree(result)
236+
237+
if expected_files:
238+
for f in expected_files:
239+
assert f in all_files, f"Expected file '{f}' not found in tree"
240+
if excluded_files:
241+
for f in excluded_files:
242+
assert f not in all_files, f"File '{f}' should be excluded from tree"
243+
244+
return result
245+
246+
return _run

0 commit comments

Comments
 (0)