Skip to content

Commit df94a54

Browse files
committed
fix: multiple bug fixes
1 parent f5ec5d5 commit df94a54

18 files changed

+1637
-74
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 }}/"

.github/workflows/ci.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,8 @@ jobs:
8686
strategy:
8787
fail-fast: false
8888
matrix:
89-
os: [ubuntu-22.04, ubuntu-24.04, macos-latest, windows-latest]
90-
python-version: [3.9, '3.10', '3.11', '3.12']
89+
os: [ubuntu-latest, macos-latest, windows-latest]
90+
python-version: [3.9, '3.10', '3.11', '3.12', '3.13']
9191

9292
runs-on: ${{ matrix.os }}
9393

@@ -137,7 +137,7 @@ jobs:
137137

138138
- name: Upload coverage for SonarCloud
139139
uses: actions/upload-artifact@v6
140-
if: matrix.os == 'ubuntu-22.04' && matrix.python-version == '3.12'
140+
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
141141
with:
142142
name: coverage-report
143143
path: |
@@ -336,7 +336,7 @@ jobs:
336336
sonar.tests=tests
337337
sonar.python.coverage.reportPaths=coverage.xml
338338
sonar.python.xunit.reportPath=test-results.xml
339-
sonar.python.version=3.9,3.10,3.11,3.12
339+
sonar.python.version=3.9,3.10,3.11,3.12,3.13
340340
EOF
341341
342342
- name: SonarCloud Scan

.treemapperignore

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

CLAUDE.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,22 +41,37 @@ children:
4141
```bash
4242
treemapper . # YAML to stdout
4343
treemapper . -o tree.yaml # save to file
44+
treemapper . -o - # explicit stdout output
4445
treemapper . --format json # JSON format
4546
treemapper . --format text # tree-style text
4647
treemapper . --no-content # structure only (no file contents)
4748
treemapper . --max-depth 3 # limit directory depth
4849
treemapper . --max-file-bytes 10000 # skip files larger than 10KB
4950
treemapper . -i custom.ignore # custom ignore patterns
51+
treemapper . --no-default-ignores # disable .gitignore/.treemapperignore (custom -i still works)
52+
treemapper . -v 2 # verbose output (0=ERROR, 1=WARNING, 2=INFO, 3=DEBUG)
53+
treemapper --version # show version
5054
```
5155

5256
## Python API
5357

5458
```python
5559
from treemapper import map_directory, to_yaml, to_json, to_text
5660

57-
# Get tree as dict
61+
# Full function signature
62+
tree = map_directory(
63+
path, # directory path (str or Path)
64+
max_depth=None, # limit traversal depth
65+
no_content=False, # exclude file contents
66+
max_file_bytes=None, # skip files larger than N bytes
67+
ignore_file=None, # custom ignore file path
68+
no_default_ignores=False, # disable .gitignore/.treemapperignore
69+
)
70+
71+
# Examples
5872
tree = map_directory("./myproject")
5973
tree = map_directory("./src", max_depth=2, no_content=True)
74+
tree = map_directory(".", max_file_bytes=50000, ignore_file="custom.ignore")
6075

6176
# Serialize to string
6277
yaml_str = to_yaml(tree)
@@ -68,6 +83,20 @@ text_str = to_text(tree)
6883

6984
Respects `.gitignore` and `.treemapperignore` automatically. Use `--no-default-ignores` to include everything.
7085

86+
Features:
87+
- Hierarchical: nested `.gitignore`/`.treemapperignore` files work at each directory level
88+
- Negation patterns: `!important.log` un-ignores a file
89+
- Anchored patterns: `/root_only.txt` matches only in root, `*.log` matches everywhere
90+
- Output file is always auto-ignored (prevents recursive inclusion)
91+
92+
## Content Placeholders
93+
94+
When file content cannot be read normally, placeholders are used:
95+
- `<file too large: N bytes>` — file exceeds `--max-file-bytes` limit
96+
- `<binary file: N bytes>` — file detected as binary (contains null bytes)
97+
- `<unreadable content: not utf-8>` — file is not valid UTF-8
98+
- `<unreadable content>` — file cannot be read (permission denied, I/O error)
99+
71100
## Development
72101

73102
```bash

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+
```

pyproject.toml

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,26 @@ description = "Export codebase structure and contents for AI/LLM context"
2121
readme = "README.md"
2222
requires-python = ">=3.9"
2323
license = { file = "LICENSE" }
24+
keywords = ["code-analysis", "directory-tree", "yaml", "json", "llm", "ai", "codebase", "context", "chatgpt", "claude", "code-context", "export", "tree"]
2425
classifiers = [
26+
"Development Status :: 5 - Production/Stable",
27+
"Environment :: Console",
28+
"Intended Audience :: Developers",
2529
"Programming Language :: Python :: 3",
30+
"Programming Language :: Python :: 3.9",
31+
"Programming Language :: Python :: 3.10",
32+
"Programming Language :: Python :: 3.11",
33+
"Programming Language :: Python :: 3.12",
34+
"Programming Language :: Python :: 3.13",
2635
"License :: OSI Approved :: Apache Software License",
2736
"Operating System :: OS Independent",
37+
"Topic :: Software Development :: Libraries :: Python Modules",
38+
"Topic :: Utilities",
39+
"Typing :: Typed",
2840
]
2941
dependencies = [
30-
"pathspec>=0.9,<1.0",
31-
"pyyaml>=5.4,<7.0",
42+
"pathspec>=0.11,<2.0",
43+
"pyyaml>=6.0.2,<8.0",
3244
]
3345

3446
[project.urls]

src/treemapper/ignore.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from pathlib import Path
44
from typing import List, Optional
55

6-
import pathspec # type: ignore
6+
import pathspec
77

88

99
def read_ignore_file(file_path: Path) -> List[str]:
@@ -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:
@@ -61,10 +67,13 @@ def _aggregate_ignore_patterns(root: Path, ignore_filename: str) -> List[str]:
6167
neg = line.startswith("!")
6268
pat = line[1:] if neg else line
6369

64-
if pat.startswith("/"):
65-
full = f"/{rel}{pat}" if rel else pat
70+
if pat.startswith("/") or "/" in pat:
71+
anchored_pat = pat.lstrip("/")
72+
full = f"/{rel}/{anchored_pat}" if rel else f"/{anchored_pat}"
73+
elif rel:
74+
full = f"{rel}/**/{pat}"
6675
else:
67-
full = f"{rel}/{pat}" if rel else pat
76+
full = pat
6877

6978
out.append(("!" + full) if neg else full)
7079

@@ -123,8 +132,8 @@ def get_ignore_specs(
123132

124133
if not no_default_ignores:
125134
patterns.extend(DEFAULT_IGNORE_PATTERNS)
126-
patterns.extend(_aggregate_ignore_patterns(root_dir, ".treemapperignore"))
127135
patterns.extend(_aggregate_ignore_patterns(root_dir, ".gitignore"))
136+
patterns.extend(_aggregate_ignore_patterns(root_dir, ".treemapperignore"))
128137

129138
if custom_ignore_file:
130139
patterns.extend(read_ignore_file(custom_ignore_file))

src/treemapper/logger.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33

44
def setup_logging(verbosity: int) -> None:
5-
"""Configure the logging level based on verbosity."""
65
level_map = {
76
0: logging.ERROR,
87
1: logging.WARNING,

0 commit comments

Comments
 (0)