Skip to content

Commit 5c7d0bc

Browse files
committed
fix: broken pipe handling, gitignore parsing, PyInstaller flag
1 parent f5ec5d5 commit 5c7d0bc

File tree

7 files changed

+234
-5
lines changed

7 files changed

+234
-5
lines changed

.github/workflows/cd.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ jobs:
184184
echo "PYTHONPATH: $PYTHONPATH"
185185
186186
# Run PyInstaller with explicit paths
187-
python -m PyInstaller --clean -y --dist ./dist/${{ matrix.asset_name }} treemapper.spec
187+
python -m PyInstaller --clean -y --distpath "./dist/${{ matrix.asset_name }}" treemapper.spec
188188
189189
# Verify the built executable exists (cross-platform)
190190
echo "Checking for built executable in: ./dist/${{ matrix.asset_name }}/"

.treemapperignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
tests

docs/Clipboard Copy.md

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# Plan: Clipboard Copy Feature
2+
3+
Add `--copy/-c` and `--copy-only` flags to copy output to system clipboard.
4+
5+
## Design
6+
7+
- **No external dependencies** — use native OS tools (pbcopy, xclip, wl-copy)
8+
- **Error handling** — warning to stderr, exit 0 (non-critical)
9+
- **Feedback**`Copied to clipboard (12.5 KB)` to stderr
10+
11+
## Flag Behavior
12+
13+
| Flags | stdout | file | clipboard |
14+
|-------|--------|------|-----------|
15+
| (none) || - | - |
16+
| `-o file` | - || - |
17+
| `-c` / `--copy` || - ||
18+
| `--copy -o file` | - |||
19+
| `--copy-only` | - | - ||
20+
21+
## Detection Logic
22+
23+
```
24+
macOS: pbcopy
25+
Windows: clip.exe
26+
Linux (Wayland): wl-copy
27+
Linux (X11): xclip -selection clipboard
28+
```
29+
30+
## Implementation
31+
32+
### NEW: `src/treemapper/clipboard.py`
33+
34+
```python
35+
import os
36+
import platform
37+
import shutil
38+
import subprocess
39+
40+
class ClipboardError(Exception):
41+
pass
42+
43+
def detect_clipboard_command() -> list[str] | None:
44+
system = platform.system()
45+
if system == "Darwin":
46+
return ["pbcopy"]
47+
if system == "Windows":
48+
return ["clip"]
49+
if system in ("Linux", "FreeBSD"):
50+
if os.environ.get("WAYLAND_DISPLAY") and shutil.which("wl-copy"):
51+
return ["wl-copy"]
52+
if os.environ.get("DISPLAY") and shutil.which("xclip"):
53+
return ["xclip", "-selection", "clipboard"]
54+
return None
55+
56+
def copy_to_clipboard(text: str) -> None:
57+
cmd = detect_clipboard_command()
58+
if cmd is None:
59+
raise ClipboardError("No clipboard tool found")
60+
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
61+
_, stderr = proc.communicate(input=text.encode("utf-8"), timeout=5)
62+
if proc.returncode != 0:
63+
raise ClipboardError(stderr.decode())
64+
65+
def clipboard_available() -> bool:
66+
return detect_clipboard_command() is not None
67+
```
68+
69+
### UPDATE: `cli.py`
70+
71+
```python
72+
# ParsedArgs
73+
copy: bool
74+
copy_only: bool
75+
76+
# Arguments
77+
parser.add_argument("-c", "--copy", action="store_true")
78+
parser.add_argument("--copy-only", action="store_true")
79+
```
80+
81+
### UPDATE: `treemapper.py`
82+
83+
```python
84+
if args.copy:
85+
try:
86+
copy_to_clipboard(output_content)
87+
print(f"Copied to clipboard ({len(output_content) / 1024:.1f} KB)", file=sys.stderr)
88+
except ClipboardError as e:
89+
logging.warning(f"Clipboard: {e}")
90+
91+
if args.copy_only and args.output_file is None:
92+
return
93+
```
94+
95+
## Testing
96+
97+
Skip clipboard tests in CI (no display):
98+
99+
```python
100+
@pytest.mark.skipif(os.environ.get("CI") or not os.environ.get("DISPLAY"))
101+
def test_clipboard_roundtrip():
102+
pass
103+
```

docs/Token Counting.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Plan: Token Counting Feature
2+
3+
Add `--tokens` flag to show token count for LLM context planning.
4+
5+
## Design
6+
7+
- **tiktoken** as optional dependency with fallback to `chars / 4`
8+
- **o200k_base** encoding (GPT-4o default)
9+
- Output to stderr only when TTY (don't break pipes)
10+
11+
## Implementation
12+
13+
### UPDATE: `pyproject.toml`
14+
15+
```toml
16+
[project.optional-dependencies]
17+
tokens = ["tiktoken>=0.7,<1.0"]
18+
```
19+
20+
### NEW: `src/treemapper/tokens.py`
21+
22+
```python
23+
import sys
24+
from dataclasses import dataclass
25+
from functools import lru_cache
26+
27+
@dataclass
28+
class TokenCountResult:
29+
count: int
30+
is_exact: bool
31+
encoding: str
32+
33+
@lru_cache(maxsize=4)
34+
def _get_encoder(encoding: str):
35+
try:
36+
import tiktoken
37+
return tiktoken.get_encoding(encoding)
38+
except (ImportError, Exception):
39+
return None
40+
41+
def count_tokens(text: str, encoding: str = "o200k_base") -> TokenCountResult:
42+
encoder = _get_encoder(encoding)
43+
if encoder:
44+
return TokenCountResult(len(encoder.encode(text)), True, encoding)
45+
return TokenCountResult(len(text) // 4, False, "approximation")
46+
47+
def print_token_summary(text: str, encoding: str = "o200k_base") -> None:
48+
if not sys.stderr.isatty():
49+
return
50+
result = count_tokens(text, encoding)
51+
prefix = "" if result.is_exact else "~"
52+
print(f"{prefix}{result.count:,} tokens ({result.encoding})", file=sys.stderr)
53+
```
54+
55+
### UPDATE: `cli.py`
56+
57+
```python
58+
# ParsedArgs
59+
show_tokens: bool
60+
token_encoding: str
61+
62+
# Arguments
63+
parser.add_argument("--tokens", action="store_true")
64+
parser.add_argument("--token-encoding", choices=["o200k_base", "cl100k_base"], default="o200k_base")
65+
```
66+
67+
### UPDATE: `treemapper.py`
68+
69+
```python
70+
if args.show_tokens:
71+
print_token_summary(output_content, args.token_encoding)
72+
```
73+
74+
## Usage
75+
76+
```bash
77+
treemapper . --tokens # 12,847 tokens (o200k_base)
78+
treemapper . --tokens --copy # tokens + clipboard
79+
80+
pip install treemapper[tokens] # exact counts with tiktoken
81+
```

src/treemapper/ignore.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@ def read_ignore_file(file_path: Path) -> List[str]:
1313

1414
try:
1515
with file_path.open("r", encoding="utf-8") as f:
16-
ignore_patterns = [line.strip() for line in f if line.strip() and not line.startswith("#")]
16+
for line in f:
17+
stripped = line.rstrip("\n\r")
18+
if not stripped.strip():
19+
continue
20+
if stripped.startswith("#"):
21+
continue
22+
ignore_patterns.append(stripped.rstrip())
1723
logging.info(f"Using ignore patterns from {file_path}")
1824
logging.debug(f"Read ignore patterns from {file_path}: {ignore_patterns}")
1925
except PermissionError:

src/treemapper/tree.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,11 @@ def _read_file_content(file_path: Path, max_file_bytes: Optional[int]) -> str:
9292

9393
if max_file_bytes is not None and file_size > max_file_bytes:
9494
logging.info(f"Skipping large file {file_path.name}: {file_size} bytes > {max_file_bytes} bytes")
95-
return f"<file too large: {file_size} bytes>"
95+
return f"<file too large: {file_size} bytes>\n"
9696

9797
if _is_binary_file(file_path):
9898
logging.debug(f"Detected binary file {file_path.name}")
99-
return f"<binary file: {file_size} bytes>"
99+
return f"<binary file: {file_size} bytes>\n"
100100

101101
content = file_path.read_text(encoding="utf-8")
102102
cleaned = content.replace("\x00", "")

src/treemapper/treemapper.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,37 @@
1+
import errno
2+
import os
3+
import sys
4+
from contextlib import contextmanager
5+
from typing import Generator
6+
7+
8+
@contextmanager
9+
def _handle_broken_pipe() -> Generator[None, None, None]:
10+
try:
11+
yield
12+
sys.stdout.flush()
13+
except BrokenPipeError:
14+
_suppress_broken_pipe()
15+
except ValueError:
16+
_suppress_broken_pipe()
17+
except OSError as e:
18+
if e.errno in (errno.EINVAL, errno.EPIPE):
19+
_suppress_broken_pipe()
20+
raise
21+
22+
23+
def _suppress_broken_pipe() -> None:
24+
try:
25+
devnull = os.open(os.devnull, os.O_WRONLY)
26+
try:
27+
os.dup2(devnull, sys.stdout.fileno())
28+
finally:
29+
os.close(devnull)
30+
except Exception:
31+
pass
32+
sys.exit(0)
33+
34+
135
def main() -> None:
236
from .cli import parse_args
337
from .ignore import get_ignore_specs
@@ -23,7 +57,11 @@ def main() -> None:
2357
"children": build_tree(args.root_dir, ctx),
2458
}
2559

26-
write_tree_to_file(directory_tree, args.output_file, args.output_format)
60+
if args.output_file is None:
61+
with _handle_broken_pipe():
62+
write_tree_to_file(directory_tree, args.output_file, args.output_format)
63+
else:
64+
write_tree_to_file(directory_tree, args.output_file, args.output_format)
2765

2866

2967
if __name__ == "__main__":

0 commit comments

Comments
 (0)