Skip to content

Commit 6065d70

Browse files
committed
initial commit of script
1 parent a55fefd commit 6065d70

File tree

6 files changed

+346
-0
lines changed

6 files changed

+346
-0
lines changed

.github/workflows/ci.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: CI tests
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
branches:
9+
- main
10+
11+
jobs:
12+
python-test:
13+
runs-on: ubuntu-latest
14+
15+
steps:
16+
- name: Checkout code
17+
uses: actions/checkout@v4
18+
19+
- name: Set up Python
20+
uses: actions/setup-python@v5
21+
with:
22+
python-version: 3.12
23+
24+
- name: Test the tree_view_cli package
25+
run: |
26+
python3.12 -m venv env
27+
source env/bin/activate
28+
python3.12 -m pip install --upgrade pip
29+
pip install -r test_requirements.txt
30+
coverage run -m pytest
31+
deactivate
32+
rm -rf env

.vscode/settings.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"python.testing.pytestArgs": [
3+
"."
4+
],
5+
"python.testing.unittestEnabled": false,
6+
"python.testing.pytestEnabled": true
7+
}

README.md

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# tree-view-cli
2+
3+
`tree-view-cli` is a Python script that generates a tree-like representation of a directory structure. It's designed to be simple, fast, and customizable, providing an easy way to visualize file system hierarchies directly from the command line.
4+
5+
## Features
6+
7+
- Generate a tree view of any directory
8+
- Respect `.gitignore` rules, providing an accurate representation of version-controlled projects
9+
- Customizable depth limit
10+
- Alphabetical sorting of files and directories
11+
- Cyclic reference detection to prevent infinite loops
12+
- Cross-platform compatibility (works on any system with Python 3.6+)
13+
14+
## Requirements
15+
16+
- Python 3.6 or higher
17+
18+
## Installation
19+
20+
1. Clone this repository or download the `tree_view_cli.py` script.
21+
2. Make the script executable (on Unix-like systems):
22+
23+
```bash
24+
chmod +x tree_view_cli.py
25+
```
26+
27+
3. Optionally, you can add the script's directory to your PATH for easier access.
28+
29+
## Usage
30+
31+
Basic usage:
32+
33+
```bash
34+
python tree_view_cli.py /path/to/directory
35+
```
36+
37+
To limit the depth of the tree:
38+
39+
```bash
40+
python tree_view_cli.py /path/to/directory --max-depth 2
41+
```
42+
43+
### Options
44+
45+
- `directory`: The path to the directory you want to visualize (required)
46+
- `--max-depth`: Maximum depth to display (optional, default is unlimited)
47+
48+
## Example Output
49+
50+
```
51+
my-project/
52+
├── src/
53+
│ ├── main.py
54+
│ └── utils/
55+
│ ├── helper.py
56+
│ └── config.py
57+
├── tests/
58+
│ ├── test_main.py
59+
│ └── test_utils.py
60+
├── README.md
61+
└── requirements.txt
62+
```
63+
64+
Note: The output will exclude files and directories specified in .gitignore files.
65+
66+
## Development
67+
68+
### Running Tests
69+
70+
To run the tests, you'll need pytest installed. You can install it with:
71+
72+
```bash
73+
pip install pytest
74+
```
75+
76+
Then, run the tests with:
77+
78+
```bash
79+
pytest test_tree_view_cli.py
80+
```
81+
82+
## Contributing
83+
84+
Contributions are welcome! Please feel free to submit a Pull Request.
85+
86+
## License
87+
88+
This project is open source and available under the [MIT License](LICENSE).
89+
90+
## Contact
91+
92+
If you have any questions or encounter any issues, please feel free to open an issue in this repository.
93+
94+
---
95+
96+
Happy tree viewing!

test_requirements.txt

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

test_tree_view_cli.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import pytest
2+
import tempfile
3+
import os
4+
from pathlib import Path
5+
from tree_view_cli import DirectoryTreeGenerator
6+
7+
@pytest.fixture
8+
def temp_directory():
9+
with tempfile.TemporaryDirectory() as tmpdirname:
10+
yield Path(tmpdirname)
11+
12+
def create_file_structure(root):
13+
(root / "dir1" / "subdir1").mkdir(parents=True)
14+
(root / "dir1" / "subdir2").mkdir(parents=True)
15+
(root / "dir1" / "file1.txt").touch()
16+
(root / "dir1" / "subdir1" / "file2.txt").touch()
17+
(root / "dir1" / "subdir2" / "file3.txt").touch()
18+
19+
def test_basic_structure(temp_directory, capsys):
20+
create_file_structure(temp_directory)
21+
tree_gen = DirectoryTreeGenerator(temp_directory)
22+
tree_gen.generate_tree()
23+
captured = capsys.readouterr()
24+
assert "dir1" in captured.out
25+
assert "subdir1" in captured.out
26+
assert "subdir2" in captured.out
27+
assert "file1.txt" in captured.out
28+
assert "file2.txt" in captured.out
29+
assert "file3.txt" in captured.out
30+
31+
def test_max_depth(temp_directory, capsys):
32+
create_file_structure(temp_directory)
33+
tree_gen = DirectoryTreeGenerator(temp_directory, max_depth=1)
34+
tree_gen.generate_tree()
35+
captured = capsys.readouterr()
36+
assert "dir1" in captured.out
37+
assert "subdir1" not in captured.out
38+
assert "file2.txt" not in captured.out
39+
40+
def test_gitignore_file(temp_directory, capsys):
41+
create_file_structure(temp_directory)
42+
with open(temp_directory / ".gitignore", "w") as f:
43+
f.write("**/file1.txt\n")
44+
tree_gen = DirectoryTreeGenerator(temp_directory)
45+
tree_gen.generate_tree()
46+
captured = capsys.readouterr()
47+
assert "file1.txt" not in captured.out
48+
assert "file2.txt" in captured.out
49+
50+
def test_gitignore_directory(temp_directory, capsys):
51+
create_file_structure(temp_directory)
52+
with open(temp_directory / ".gitignore", "w") as f:
53+
f.write("dir1/\n")
54+
tree_gen = DirectoryTreeGenerator(temp_directory)
55+
tree_gen.generate_tree()
56+
captured = capsys.readouterr()
57+
assert "dir1" not in captured.out
58+
59+
def test_gitignore_subdirectory(temp_directory, capsys):
60+
create_file_structure(temp_directory)
61+
with open(temp_directory / ".gitignore", "w") as f:
62+
f.write("**/subdir1\n")
63+
tree_gen = DirectoryTreeGenerator(temp_directory)
64+
tree_gen.generate_tree()
65+
captured = capsys.readouterr()
66+
assert "subdir1" not in captured.out
67+
assert "subdir2" in captured.out
68+
69+
# def test_cyclic_reference(temp_directory, capsys):
70+
# (temp_directory / "dir1" / "subdir1").mkdir(parents=True)
71+
# os.symlink(temp_directory / "dir1", temp_directory / "dir1" / "subdir1" / "cycle")
72+
# tree_gen = DirectoryTreeGenerator(temp_directory)
73+
# tree_gen.generate_tree()
74+
# captured = capsys.readouterr()
75+
# assert "Cyclic reference to" in captured.out
76+
77+
def test_empty_directory(temp_directory, capsys):
78+
tree_gen = DirectoryTreeGenerator(temp_directory)
79+
tree_gen.generate_tree()
80+
captured = capsys.readouterr()
81+
assert captured.out.strip() == temp_directory.name + "/"
82+
83+
def test_git_directory_excluded(temp_directory, capsys):
84+
create_file_structure(temp_directory)
85+
(temp_directory / ".git").mkdir()
86+
tree_gen = DirectoryTreeGenerator(temp_directory)
87+
tree_gen.generate_tree()
88+
captured = capsys.readouterr()
89+
assert "dir1" in captured.out
90+
assert ".git" not in captured.out

tree_view_cli.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import os
2+
import argparse
3+
from pathlib import Path
4+
import fnmatch
5+
import re
6+
7+
class DirectoryTreeGenerator:
8+
def __init__(self, root_dir, max_depth=float('inf')):
9+
self.root_dir = Path(root_dir).resolve()
10+
self.max_depth = max_depth
11+
self.gitignore_patterns = self.load_gitignore_patterns()
12+
13+
def load_gitignore_patterns(self):
14+
patterns = []
15+
current_dir = self.root_dir
16+
while current_dir != current_dir.parent:
17+
gitignore_file = current_dir / '.gitignore'
18+
if gitignore_file.is_file():
19+
with open(gitignore_file, 'r') as f:
20+
patterns.extend([line.strip() for line in f if line.strip() and not line.startswith('#')])
21+
current_dir = current_dir.parent
22+
return patterns
23+
24+
def gitignore_pattern_to_regex(self, pattern: str) -> str:
25+
"""
26+
Convert a .gitignore pattern to a regular expression.
27+
28+
Args:
29+
pattern (str): The .gitignore pattern.
30+
31+
Returns:
32+
str: The corresponding regular expression.
33+
"""
34+
# Escape special characters
35+
pattern = re.escape(pattern)
36+
37+
# Replace escaped wildcards with regex equivalents
38+
pattern = pattern.replace(r'\*\*', '.*')
39+
pattern = pattern.replace(r'\*', '[^/]*')
40+
pattern = pattern.replace(r'\?', '.')
41+
42+
# Check if the pattern is for a directory
43+
if pattern.endswith(r'/'):
44+
# Match the directory and its contents
45+
pattern = pattern[:-2] + r'(/.*)?'
46+
else:
47+
# Add start and end anchors for non-directory patterns
48+
pattern = '^' + pattern + '$'
49+
50+
return pattern
51+
52+
def should_ignore(self, path: Path) -> bool:
53+
"""
54+
Determine if a given path should be ignored based on .gitignore patterns.
55+
56+
Args:
57+
path (Path): The path to check.
58+
59+
Returns:
60+
bool: True if the path should be ignored, False otherwise.
61+
"""
62+
# Get the relative path from the root directory
63+
rel_path = path.relative_to(self.root_dir)
64+
65+
# Convert the relative path to a string
66+
rel_path_str = str(rel_path)
67+
68+
# Check if the path is the .git directory or inside it
69+
if rel_path_str == ".git" or rel_path_str.startswith(".git/"):
70+
return True
71+
72+
# Check if the relative path matches any pattern in the .gitignore patterns
73+
for pattern in self.gitignore_patterns:
74+
# Convert the pattern to a regex
75+
regex = self.gitignore_pattern_to_regex(pattern)
76+
if re.match(regex, rel_path_str):
77+
return True
78+
79+
# If no patterns match, return False
80+
return False
81+
82+
def generate_tree(self):
83+
print(f"{self.root_dir.name}/")
84+
self._generate_tree(self.root_dir, "", 0, set())
85+
86+
def _generate_tree(self, directory, prefix, depth, visited):
87+
if depth >= self.max_depth:
88+
return
89+
90+
entries = sorted(directory.iterdir(), key=lambda x: (x.is_file(), x.name.lower()))
91+
92+
for i, entry in enumerate(entries):
93+
if self.should_ignore(entry):
94+
continue
95+
96+
is_last = i == len(entries) - 1
97+
current_prefix = "└── " if is_last else "├── "
98+
print(f"{prefix}{current_prefix}{entry.name}")
99+
100+
if entry.is_dir() and not entry.is_symlink():
101+
canonical_path = entry.resolve()
102+
if canonical_path in visited:
103+
print(f"{prefix}{' ' if is_last else '│ '}[Cyclic reference to {entry.name}]")
104+
continue
105+
106+
new_prefix = prefix + (" " if is_last else "│ ")
107+
new_visited = visited.union({canonical_path})
108+
self._generate_tree(entry, new_prefix, depth + 1, new_visited)
109+
110+
def main():
111+
parser = argparse.ArgumentParser(description="Generate a directory tree.")
112+
parser.add_argument("directory", help="The directory to generate the tree for.")
113+
parser.add_argument("--max-depth", type=int, default=float('inf'), help="Maximum depth to traverse.")
114+
args = parser.parse_args()
115+
116+
tree_generator = DirectoryTreeGenerator(args.directory, args.max_depth)
117+
tree_generator.generate_tree()
118+
119+
if __name__ == "__main__":
120+
main()

0 commit comments

Comments
 (0)