From f67e92006796f558a2dc63ec3d8251678364fef5 Mon Sep 17 00:00:00 2001 From: Davi RF Date: Mon, 16 Mar 2026 21:20:07 -0300 Subject: [PATCH 1/9] Improve CLI validation --- rptree/__main__.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/rptree/__main__.py b/rptree/__main__.py index 0613440..30a68f5 100644 --- a/rptree/__main__.py +++ b/rptree/__main__.py @@ -1,23 +1,31 @@ """This module provides the RP Tree CLI.""" import pathlib -import sys from .cli import parse_cmd_line_arguments from .rptree import DirectoryTree -def main(): +def main() -> None: args = parse_cmd_line_arguments() - root_dir = pathlib.Path(args.root_dir) + root_dir = pathlib.Path(args.root_dir).resolve() + + if not root_dir.exists(): + print(f"Directory not found: {root_dir}") + exit() + if not root_dir.is_dir(): - print("The specified root directory doesn't exist") - sys.exit() + print(f"Path is not a directory: {root_dir}") + exit() + tree = DirectoryTree( - root_dir, dir_only=args.dir_only, output_file=args.output_file + root_dir, + dir_only=args.dir_only, + output_file=args.output_file, ) + tree.generate() if __name__ == "__main__": - main() + main() \ No newline at end of file From 4c8c40b292405517c0bf395e4c7940622fef42f6 Mon Sep 17 00:00:00 2001 From: Davi RF Date: Mon, 16 Mar 2026 21:27:20 -0300 Subject: [PATCH 2/9] Fix CLI program name in argparse --- rptree/cli.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/rptree/cli.py b/rptree/cli.py index c12eb43..afbfa97 100644 --- a/rptree/cli.py +++ b/rptree/cli.py @@ -1,19 +1,21 @@ """This module provides the RP Tree CLI.""" import argparse -import sys from . import __version__ -def parse_cmd_line_arguments(): +def parse_cmd_line_arguments() -> argparse.Namespace: parser = argparse.ArgumentParser( - prog="tree", - description="RP Tree, a directory tree generator", + prog="rptree", + description="Generate a directory tree", epilog="Thanks for using RP Tree!", ) + parser.version = f"RP Tree v{__version__}" + parser.add_argument("-v", "--version", action="version") + parser.add_argument( "root_dir", metavar="ROOT_DIR", @@ -21,18 +23,20 @@ def parse_cmd_line_arguments(): default=".", help="generate a full directory tree starting at ROOT_DIR", ) + parser.add_argument( "-d", "--dir-only", action="store_true", help="generate a directory-only tree", ) + parser.add_argument( "-o", "--output-file", metavar="OUTPUT_FILE", - nargs="?", - default=sys.stdout, + default=None, help="generate a full directory tree and save it to a file", ) - return parser.parse_args() + + return parser.parse_args() \ No newline at end of file From 7c0daba60d428e7e94f0c01c10ddbcf635ea7a4d Mon Sep 17 00:00:00 2001 From: Davi RF Date: Mon, 16 Mar 2026 21:57:23 -0300 Subject: [PATCH 3/9] feat: enhance tree generator with ordering options, ignore patterns, gitignore support and type hints --- rptree/cli.py | 54 +++++++++++++- rptree/rptree.py | 180 +++++++++++++++++++++++++++++++++++------------ 2 files changed, 186 insertions(+), 48 deletions(-) diff --git a/rptree/cli.py b/rptree/cli.py index afbfa97..e38cb99 100644 --- a/rptree/cli.py +++ b/rptree/cli.py @@ -31,12 +31,62 @@ def parse_cmd_line_arguments() -> argparse.Namespace: help="generate a directory-only tree", ) + parser.add_argument( + "-f", + "--files-only", + action="store_true", + help="generate a file-only tree", + ) + + ordering = parser.add_mutually_exclusive_group() + + ordering.add_argument( + "-df", + "--dirs-first", + action="store_true", + help="list directories before files", + ) + + ordering.add_argument( + "-ff", + "--files-first", + action="store_true", + help="list files before directories", + ) + + parser.add_argument( + "-i" + "--ignore", + metavar="NAME", + nargs="*", + default=None, + help="ignore specific files or directories", + ) + + parser.add_argument( + "-gi", + "--gitignore", + action="store_true", + help="respect .gitignore rules", + ) + parser.add_argument( "-o", "--output-file", metavar="OUTPUT_FILE", default=None, - help="generate a full directory tree and save it to a file", + help="save the generated tree to a file", ) - return parser.parse_args() \ No newline at end of file + args = parser.parse_args() + + if args.dir_only and args.files_only: + parser.error("cannot use --dir-only and --files-only together") + + if args.dir_only and (args.dirs_first or args.files_first): + parser.error("ordering options are not valid with --dir-only") + + if args.files_only and (args.dirs_first or args.files_first): + parser.error("ordering options are not valid with --files-only") + + return args \ No newline at end of file diff --git a/rptree/rptree.py b/rptree/rptree.py index 2581c0a..3e9ff43 100644 --- a/rptree/rptree.py +++ b/rptree/rptree.py @@ -1,9 +1,8 @@ """This module provides RP Tree main module.""" -import os import pathlib -import sys from collections import deque +from typing import Iterable, Optional, Set PIPE = "│" ELBOW = "└──" @@ -13,74 +12,163 @@ class DirectoryTree: - def __init__(self, root_dir, dir_only=False, output_file=sys.stdout): + def __init__( + self, + root_dir: str | pathlib.Path, + dir_only: bool = False, + files_only: bool = False, + dirs_first: bool = False, + files_first: bool = False, + output_file: Optional[str] = None, + ignore: Optional[Iterable[str]] = None, + use_gitignore: bool = False, + ) -> None: + self._root_dir = pathlib.Path(root_dir) self._output_file = output_file - self._generator = _TreeGenerator(root_dir, dir_only) - def generate(self): + if dir_only and files_only: + raise ValueError("Cannot use dir_only and files_only together") + + if dir_only and (dirs_first or files_first): + raise ValueError("Ordering options are not valid with dir_only") + + if files_only and (dirs_first or files_first): + raise ValueError("Ordering options are not valid with files_only") + + if dirs_first and files_first: + raise ValueError("Cannot enable dirs_first and files_first together") + + self._generator = _TreeGenerator( + root_dir=self._root_dir, + dir_only=dir_only, + files_only=files_only, + dirs_first=dirs_first, + files_first=files_first, + ignore=ignore, + use_gitignore=use_gitignore, + ) + + def generate(self) -> None: tree = self._generator.build_tree() - if self._output_file != sys.stdout: - # Wrap the tree in a markdown code block - tree.appendleft("```") - tree.append("```") - self._output_file = open( - self._output_file, mode="w", encoding="UTF-8" - ) - with self._output_file as stream: + + if self._output_file: + with open(self._output_file, "w", encoding="utf-8") as stream: + for entry in tree: + print(entry, file=stream) + else: for entry in tree: - print(entry, file=stream) + print(entry) class _TreeGenerator: - def __init__(self, root_dir, dir_only=False): - self._root_dir = pathlib.Path(root_dir) - self._dir_only = dir_only - self._tree = deque() + def __init__( + self, + root_dir: pathlib.Path, + dir_only: bool = False, + files_only: bool = False, + dirs_first: bool = False, + files_first: bool = False, + ignore: Optional[Iterable[str]] = None, + use_gitignore: bool = False, + ) -> None: + self._root_dir: pathlib.Path = root_dir + self._dir_only: bool = dir_only + self._files_only: bool = files_only + self._dirs_first: bool = dirs_first + self._files_first: bool = files_first + self._ignore: Set[str] = set(ignore or []) + self._tree: deque[str] = deque() + self._gitignore_spec = None + + if use_gitignore: + self._load_gitignore() + + def _load_gitignore(self) -> None: + gitignore = self._root_dir / ".gitignore" + if not gitignore.exists(): + return - def build_tree(self): + try: + import pathspec + + with open(gitignore) as f: + self._gitignore_spec = pathspec.PathSpec.from_lines( + "gitwildmatch", f + ) + except ImportError: + pass + + def build_tree(self) -> deque[str]: self._tree_head() self._tree_body(self._root_dir) return self._tree - def _tree_head(self): - self._tree.append(f"{self._root_dir}{os.sep}") + def _tree_head(self) -> None: + self._tree.append(f"{self._root_dir}/") - def _tree_body(self, directory, prefix=""): + def _tree_body(self, directory: pathlib.Path, prefix: str = "") -> None: entries = self._prepare_entries(directory) last_index = len(entries) - 1 + for index, entry in enumerate(entries): connector = ELBOW if index == last_index else TEE + if entry.is_dir(): - if index == 0: - self._tree.append(prefix + PIPE) - self._add_directory( - entry, index, last_index, prefix, connector - ) + self._add_directory(entry, index, last_index, prefix, connector) else: self._add_file(entry, prefix, connector) - def _prepare_entries(self, directory): - entries = sorted( - directory.iterdir(), key=lambda entry: str(entry) - ) + def _prepare_entries(self, directory: pathlib.Path) -> list[pathlib.Path]: + entries: list[pathlib.Path] = [] + + for entry in directory.iterdir(): + if entry.name in self._ignore: + continue + + if self._gitignore_spec and self._gitignore_spec.match_file( + str(entry.relative_to(self._root_dir)) + ): + continue + + entries.append(entry) + if self._dir_only: - return [entry for entry in entries if entry.is_dir()] - return sorted(entries, key=lambda entry: entry.is_file()) + entries = [e for e in entries if e.is_dir()] + + if self._files_only: + entries = [e for e in entries if e.is_file()] + + entries.sort(key=lambda e: e.name.lower()) + + if self._dirs_first: + entries.sort(key=lambda e: e.is_file()) + + if self._files_first: + entries.sort(key=lambda e: e.is_dir()) + + return entries def _add_directory( - self, directory, index, last_index, prefix, connector - ): - self._tree.append(f"{prefix}{connector} {directory.name}{os.sep}") + self, + directory: pathlib.Path, + index: int, + last_index: int, + prefix: str, + connector: str, + ) -> None: + self._tree.append(f"{prefix}{connector} {directory.name}/") + if index != last_index: - prefix += PIPE_PREFIX + new_prefix = prefix + PIPE_PREFIX else: - prefix += SPACE_PREFIX - self._tree_body( - directory=directory, - prefix=prefix, - ) - if prefix := prefix.rstrip(): - self._tree.append(prefix) + new_prefix = prefix + SPACE_PREFIX + + self._tree_body(directory, new_prefix) - def _add_file(self, file, prefix, connector): - self._tree.append(f"{prefix}{connector} {file.name}") + def _add_file( + self, + file: pathlib.Path, + prefix: str, + connector: str, + ) -> None: + self._tree.append(f"{prefix}{connector} {file.name}") \ No newline at end of file From 220f60a2291d6899c589faed403821a5a7068425 Mon Sep 17 00:00:00 2001 From: Davi RF Date: Mon, 16 Mar 2026 22:01:28 -0300 Subject: [PATCH 4/9] fix: pass new CLI arguments to DirectoryTree in main entrypoint --- rptree/__main__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/rptree/__main__.py b/rptree/__main__.py index 30a68f5..a089403 100644 --- a/rptree/__main__.py +++ b/rptree/__main__.py @@ -19,9 +19,14 @@ def main() -> None: exit() tree = DirectoryTree( - root_dir, + root_dir=root_dir, dir_only=args.dir_only, + files_only=args.files_only, + dirs_first=args.dirs_first, + files_first=args.files_first, output_file=args.output_file, + ignore=args.ignore, + use_gitignore=args.gitignore, ) tree.generate() From 11bae2ea65c337ff9bdda0bf28ea69562c0c3a5e Mon Sep 17 00:00:00 2001 From: Davi RF Date: Tue, 17 Mar 2026 10:15:39 -0300 Subject: [PATCH 5/9] Add depth limit option and improve CLI and tree generation - Readme updated --- README.md | 150 +++++++++++++++++++++++++++--------- rptree/__init__.py | 2 +- rptree/__main__.py | 10 ++- rptree/cli.py | 19 ++++- rptree/rptree.py | 185 ++++++++++++++++++++------------------------- setup.py | 35 +++------ 6 files changed, 229 insertions(+), 172 deletions(-) diff --git a/README.md b/README.md index f647053..97aa7d8 100644 --- a/README.md +++ b/README.md @@ -4,59 +4,137 @@ RP Tree is a command-line tool to generate directory tree diagrams. ## Installation -To install **RP Tree**, just run the following command: - -```sh -$ pip install rptree +```bash +pip install rptree ``` ## Usage -```sh -$ rptree /path/to/directory/ +```bash +rptree [ROOT_DIR] +``` + +If no directory is provided, the current directory is used. + +```bash +rptree +``` + +Show help: + +```bash +rptree -h +``` + +## Options + +### General + +- `-h`, `--help` Show help message +- `-v`, `--version` Show application version + +### Modes + +- `-d`, `--dir-only` Display directories only +- `-f`, `--files-only` Display files only + +### Ordering + +- `-df`, `--dirs-first` List directories before files +- `-ff`, `--files-first` List files before directories + +> Note: Alphabetical order is always used as the base ordering. + +### Ignoring + +- `-i`, `--ignore NAME [NAME ...]` Ignore specific files or directories +- `-gi`, `--gitignore` Respect `.gitignore` rules + +### Depth + +- `-dl`, `--depth-level N` Limit the tree depth to N levels + +### Output + +- `-o`, `--output-file FILE` Save the generated tree to a file + +## Examples + +Generate tree for current directory: + +```bash +rptree +``` + +Directories first: + +```bash +rptree . -df +``` + +Files only: + +```bash +rptree . -f +``` + +Limit depth: + +```bash +rptree . -dl 2 +``` + +Ignore specific entries: + +```bash +rptree . -i node_modules dist .git ``` -**Note:** The `-h` or `--help` option provides help on using RP Tree. +Use `.gitignore` rules: + +```bash +rptree . -gi +``` + +Save output to file: + +```bash +rptree . -o tree.txt +``` ## Sample Output -```sh -$ rptree hello/ -./hello/ -│ -├── hello/ -│ ├── __init__.py -│ └── hello.py -│ +```bash +project/ +├── src/ +│ ├── main.py +│ └── utils.py ├── tests/ -│ └── test_hello.py -│ -├── LICENSE -├── README.md -├── requirements.txt -└── setup.py +│ └── test_main.py +└── README.md ``` -That's it! You've generated a nice directory tree diagram. +## Release History -## Features +### 0.2.0 -If you run RP Tree with a directory path as an argument, then you get a full directory tree diagram printed on your screen. The default input directory is your current directory. +- Added `--files-only` (`-f`) +- Added `--dirs-first` (`-df`) +- Added `--files-first` (`-ff`) +- Added `--ignore` (`-i`) +- Added `.gitignore` support (`-gi`) +- Added `--depth-level` (`-dl`) +- Improved tree formatting +- Added type hints -RP Tree also provides the following options: +### 0.1.1 -- `-v`, `--version` shows the application version and exits -- `-h`, `--help` show a usage message -- `-d`, `--dir-only` generates a directory-only tree diagram -- `-o`, `--output-file` generates a full directory tree diagram and save it to a file in markdown format +- Display entries in alphabetical order -## Release History +### 0.1.0 -- 0.1.1 - - Display the entries in alphabetical order -- 0.1.0 - - A work in progress +- Initial release -## About the Author +## Author -Leodanis Pozo Ramos - Email: leodanis@realpython.com +Leodanis Pozo Ramos diff --git a/rptree/__init__.py b/rptree/__init__.py index e8d5a69..6d8c007 100644 --- a/rptree/__init__.py +++ b/rptree/__init__.py @@ -1,3 +1,3 @@ """Top-level package for RP Tree.""" -__version__ = "0.1.1" +__version__ = "0.2.0" diff --git a/rptree/__main__.py b/rptree/__main__.py index a089403..56415f1 100644 --- a/rptree/__main__.py +++ b/rptree/__main__.py @@ -1,6 +1,7 @@ -"""This module provides the RP Tree CLI.""" +"""Entry point for RP Tree CLI.""" import pathlib +import sys from .cli import parse_cmd_line_arguments from .rptree import DirectoryTree @@ -12,11 +13,11 @@ def main() -> None: if not root_dir.exists(): print(f"Directory not found: {root_dir}") - exit() + sys.exit(1) if not root_dir.is_dir(): print(f"Path is not a directory: {root_dir}") - exit() + sys.exit(1) tree = DirectoryTree( root_dir=root_dir, @@ -24,9 +25,10 @@ def main() -> None: files_only=args.files_only, dirs_first=args.dirs_first, files_first=args.files_first, - output_file=args.output_file, ignore=args.ignore, use_gitignore=args.gitignore, + depth_level=args.depth_level, + output_file=args.output_file, ) tree.generate() diff --git a/rptree/cli.py b/rptree/cli.py index e38cb99..37c27e4 100644 --- a/rptree/cli.py +++ b/rptree/cli.py @@ -13,7 +13,6 @@ def parse_cmd_line_arguments() -> argparse.Namespace: ) parser.version = f"RP Tree v{__version__}" - parser.add_argument("-v", "--version", action="version") parser.add_argument( @@ -55,12 +54,12 @@ def parse_cmd_line_arguments() -> argparse.Namespace: ) parser.add_argument( - "-i" + "-i", "--ignore", - metavar="NAME", + metavar="PATTERN", nargs="*", default=None, - help="ignore specific files or directories", + help="ignore files or directories (supports patterns, e.g. *.py)", ) parser.add_argument( @@ -70,6 +69,15 @@ def parse_cmd_line_arguments() -> argparse.Namespace: help="respect .gitignore rules", ) + parser.add_argument( + "-dl", + "--depth-level", + metavar="N", + type=int, + default=None, + help="limit tree depth to N levels", + ) + parser.add_argument( "-o", "--output-file", @@ -89,4 +97,7 @@ def parse_cmd_line_arguments() -> argparse.Namespace: if args.files_only and (args.dirs_first or args.files_first): parser.error("ordering options are not valid with --files-only") + if args.depth_level is not None and args.depth_level < 0: + parser.error("--depth-level must be >= 0") + return args \ No newline at end of file diff --git a/rptree/rptree.py b/rptree/rptree.py index 3e9ff43..2ddb000 100644 --- a/rptree/rptree.py +++ b/rptree/rptree.py @@ -1,8 +1,12 @@ """This module provides RP Tree main module.""" +from __future__ import annotations + import pathlib +import sys from collections import deque -from typing import Iterable, Optional, Set +from fnmatch import fnmatch +from typing import Deque, List, Optional, Set PIPE = "│" ELBOW = "└──" @@ -14,99 +18,96 @@ class DirectoryTree: def __init__( self, - root_dir: str | pathlib.Path, + root_dir: pathlib.Path, + *, dir_only: bool = False, files_only: bool = False, dirs_first: bool = False, files_first: bool = False, - output_file: Optional[str] = None, - ignore: Optional[Iterable[str]] = None, + ignore: Optional[List[str]] = None, use_gitignore: bool = False, + depth_level: Optional[int] = None, + output_file: Optional[str] = None, ) -> None: - self._root_dir = pathlib.Path(root_dir) self._output_file = output_file - - if dir_only and files_only: - raise ValueError("Cannot use dir_only and files_only together") - - if dir_only and (dirs_first or files_first): - raise ValueError("Ordering options are not valid with dir_only") - - if files_only and (dirs_first or files_first): - raise ValueError("Ordering options are not valid with files_only") - - if dirs_first and files_first: - raise ValueError("Cannot enable dirs_first and files_first together") - self._generator = _TreeGenerator( - root_dir=self._root_dir, + root_dir=root_dir, dir_only=dir_only, files_only=files_only, dirs_first=dirs_first, files_first=files_first, ignore=ignore, use_gitignore=use_gitignore, + depth_level=depth_level, ) def generate(self) -> None: tree = self._generator.build_tree() if self._output_file: - with open(self._output_file, "w", encoding="utf-8") as stream: - for entry in tree: - print(entry, file=stream) + tree.appendleft("```") + tree.append("```") + with open(self._output_file, "w", encoding="utf-8") as f: + for line in tree: + print(line, file=f) else: - for entry in tree: - print(entry) + for line in tree: + print(line) class _TreeGenerator: def __init__( self, + *, root_dir: pathlib.Path, - dir_only: bool = False, - files_only: bool = False, - dirs_first: bool = False, - files_first: bool = False, - ignore: Optional[Iterable[str]] = None, - use_gitignore: bool = False, + dir_only: bool, + files_only: bool, + dirs_first: bool, + files_first: bool, + ignore: Optional[List[str]], + use_gitignore: bool, + depth_level: Optional[int], ) -> None: - self._root_dir: pathlib.Path = root_dir - self._dir_only: bool = dir_only - self._files_only: bool = files_only - self._dirs_first: bool = dirs_first - self._files_first: bool = files_first + self._root_dir = root_dir + self._dir_only = dir_only + self._files_only = files_only + self._dirs_first = dirs_first + self._files_first = files_first self._ignore: Set[str] = set(ignore or []) - self._tree: deque[str] = deque() - self._gitignore_spec = None + self._depth_level = depth_level + self._tree: Deque[str] = deque() + self._gitignore = None if use_gitignore: - self._load_gitignore() - - def _load_gitignore(self) -> None: - gitignore = self._root_dir / ".gitignore" - if not gitignore.exists(): - return - - try: - import pathspec - - with open(gitignore) as f: - self._gitignore_spec = pathspec.PathSpec.from_lines( - "gitwildmatch", f + try: + import pathspec + + gitignore = root_dir / ".gitignore" + if gitignore.exists(): + patterns = gitignore.read_text().splitlines() + self._gitignore = pathspec.PathSpec.from_lines( + "gitwildmatch", patterns + ) + except ImportError: + print( + "Warning: pathspec not installed, .gitignore ignored", + file=sys.stderr, ) - except ImportError: - pass - def build_tree(self) -> deque[str]: - self._tree_head() - self._tree_body(self._root_dir) + def build_tree(self) -> Deque[str]: + self._tree.append(f"{self._root_dir.name}/") + self._tree_body(self._root_dir, depth=0) return self._tree - def _tree_head(self) -> None: - self._tree.append(f"{self._root_dir}/") + def _tree_body( + self, + directory: pathlib.Path, + prefix: str = "", + depth: int = 0, + ) -> None: + if self._depth_level is not None and depth >= self._depth_level: + return - def _tree_body(self, directory: pathlib.Path, prefix: str = "") -> None: entries = self._prepare_entries(directory) last_index = len(entries) - 1 @@ -114,61 +115,41 @@ def _tree_body(self, directory: pathlib.Path, prefix: str = "") -> None: connector = ELBOW if index == last_index else TEE if entry.is_dir(): - self._add_directory(entry, index, last_index, prefix, connector) + self._tree.append(f"{prefix}{connector} {entry.name}/") + extension = SPACE_PREFIX if index == last_index else PIPE_PREFIX + self._tree_body(entry, prefix + extension, depth + 1) else: - self._add_file(entry, prefix, connector) - - def _prepare_entries(self, directory: pathlib.Path) -> list[pathlib.Path]: - entries: list[pathlib.Path] = [] + self._tree.append(f"{prefix}{connector} {entry.name}") - for entry in directory.iterdir(): - if entry.name in self._ignore: - continue + def _prepare_entries(self, directory: pathlib.Path) -> List[pathlib.Path]: + entries = sorted(directory.iterdir(), key=lambda e: e.name.lower()) - if self._gitignore_spec and self._gitignore_spec.match_file( - str(entry.relative_to(self._root_dir)) - ): - continue - - entries.append(entry) + entries = [e for e in entries if not self._is_ignored(e)] if self._dir_only: - entries = [e for e in entries if e.is_dir()] + return [e for e in entries if e.is_dir()] if self._files_only: - entries = [e for e in entries if e.is_file()] - - entries.sort(key=lambda e: e.name.lower()) + return [e for e in entries if e.is_file()] if self._dirs_first: - entries.sort(key=lambda e: e.is_file()) - - if self._files_first: - entries.sort(key=lambda e: e.is_dir()) + entries.sort(key=lambda e: (e.is_file(), e.name.lower())) + elif self._files_first: + entries.sort(key=lambda e: (e.is_dir(), e.name.lower())) return entries - def _add_directory( - self, - directory: pathlib.Path, - index: int, - last_index: int, - prefix: str, - connector: str, - ) -> None: - self._tree.append(f"{prefix}{connector} {directory.name}/") - - if index != last_index: - new_prefix = prefix + PIPE_PREFIX - else: - new_prefix = prefix + SPACE_PREFIX + def _is_ignored(self, entry: pathlib.Path) -> bool: + for pattern in self._ignore: + if fnmatch(entry.name, pattern): + return True - self._tree_body(directory, new_prefix) + if self._gitignore: + try: + rel = entry.relative_to(self._root_dir) + if self._gitignore.match_file(str(rel)): + return True + except ValueError: + pass - def _add_file( - self, - file: pathlib.Path, - prefix: str, - connector: str, - ) -> None: - self._tree.append(f"{prefix}{connector} {file.name}") \ No newline at end of file + return False \ No newline at end of file diff --git a/setup.py b/setup.py index 3ddee53..87ae9eb 100644 --- a/setup.py +++ b/setup.py @@ -1,38 +1,23 @@ -import pathlib -from setuptools import setup +from setuptools import setup, find_packages from rptree import __version__ -HERE = pathlib.Path().cwd() -DESCRIPTION = HERE.joinpath("README.md").read_text() -VERSION = __version__ - setup( name="rptree", - version=VERSION, - description="Generate directory tree diagrams for Real Python articles", - long_description=DESCRIPTION, - long_description_content_type="text/markdown", - url="https://github.com/realpython/rptree", + version=__version__, + description="A directory tree generator for the command line", author="Real Python", - author_email="info@realpython.com", - maintainer="Leodanis Pozo Ramos", - maintainer_email="leodanis@realpython.com", license="MIT", - classifiers=[ - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: Implementation :: CPython", - ], - packages=["rptree"], + packages=find_packages(), include_package_data=True, + install_requires=[ + "pathspec>=0.10", + ], entry_points={ "console_scripts": [ "rptree=rptree.__main__:main", - ] + ], }, -) + python_requires=">=3.8", +) \ No newline at end of file From 04b7cb182f2028d266376d56bc64bafcf2219cab Mon Sep 17 00:00:00 2001 From: Davi RF Date: Wed, 18 Mar 2026 13:37:59 -0300 Subject: [PATCH 6/9] feat: improve tree rendering, fix no-pipes and .gitignore handling, migrate to pyproject --- MANIFEST.in | 3 +- README.md | 90 ++++++++++++++++++++++++++++++++-------------- pyproject.toml | 23 ++++++++++++ rptree/__init__.py | 4 ++- rptree/__main__.py | 40 +++++++++++++-------- rptree/cli.py | 53 ++++++++++++++------------- rptree/rptree.py | 60 +++++++++++++++++++++---------- setup.py | 23 ------------ 8 files changed, 188 insertions(+), 108 deletions(-) create mode 100644 pyproject.toml delete mode 100644 setup.py diff --git a/MANIFEST.in b/MANIFEST.in index 1aba38f..74215c3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,2 @@ -include LICENSE +include README.md +include LICENSE \ No newline at end of file diff --git a/README.md b/README.md index 97aa7d8..5889c5e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # RP Tree -RP Tree is a command-line tool to generate directory tree diagrams. +RP Tree is a simple and fast CLI tool to generate directory tree diagrams. ## Installation @@ -14,7 +14,7 @@ pip install rptree rptree [ROOT_DIR] ``` -If no directory is provided, the current directory is used. +If no directory is provided, the current directory is used: ```bash rptree @@ -31,36 +31,40 @@ rptree -h ### General - `-h`, `--help` Show help message -- `-v`, `--version` Show application version +- `-v`, `--version` Show version ### Modes -- `-d`, `--dir-only` Display directories only -- `-f`, `--files-only` Display files only +- `-d`, `--dir-only` Show directories only +- `-f`, `--files-only` Show files only ### Ordering - `-df`, `--dirs-first` List directories before files - `-ff`, `--files-first` List files before directories -> Note: Alphabetical order is always used as the base ordering. +> Alphabetical order is always applied as base sorting. + +### Display + +- `-n`, `--no-pipes` Remove vertical pipes between branches ### Ignoring -- `-i`, `--ignore NAME [NAME ...]` Ignore specific files or directories +- `-i`, `--ignore PATTERN [PATTERN ...]` Ignore files/directories - `-gi`, `--gitignore` Respect `.gitignore` rules ### Depth -- `-dl`, `--depth-level N` Limit the tree depth to N levels +- `-dl`, `--depth-level N` Limit depth ### Output -- `-o`, `--output-file FILE` Save the generated tree to a file +- `-o`, `--output-file FILE` Save output to file (Markdown format) ## Examples -Generate tree for current directory: +Basic: ```bash rptree @@ -84,27 +88,50 @@ Limit depth: rptree . -dl 2 ``` -Ignore specific entries: +Ignore entries: ```bash rptree . -i node_modules dist .git ``` -Use `.gitignore` rules: +Use `.gitignore`: ```bash rptree . -gi ``` -Save output to file: +No pipes mode: ```bash -rptree . -o tree.txt +rptree . -n ``` -## Sample Output +Save to file: ```bash +rptree . -o tree.md +``` + +## Sample Output + +### Default + +```text +project/ +│ +├── src/ +│ ├── main.py +│ └── utils.py +│ +├── tests/ +│ └── test_main.py +│ +└── README.md +``` + +### No pipes (`-n`) + +```text project/ ├── src/ │ ├── main.py @@ -114,22 +141,29 @@ project/ └── README.md ``` +## Features + +- Clean and readable tree output +- `.gitignore` support +- Custom ignore patterns +- Depth limiting +- Flexible sorting +- Optional compact mode (`--no-pipes`) + ## Release History -### 0.2.0 +### 0.3.0 -- Added `--files-only` (`-f`) -- Added `--dirs-first` (`-df`) -- Added `--files-first` (`-ff`) -- Added `--ignore` (`-i`) -- Added `.gitignore` support (`-gi`) -- Added `--depth-level` (`-dl`) -- Improved tree formatting -- Added type hints +- Fixed `--no-pipes` behavior +- Fixed `.gitignore` directory handling +- Improved tree formatting (matches classic `tree`) +- Added modern packaging (`pyproject.toml`) -### 0.1.1 +### 0.2.0 -- Display entries in alphabetical order +- Added filtering and ordering options +- Added `.gitignore` support +- Added depth control ### 0.1.0 @@ -138,3 +172,7 @@ project/ ## Author Leodanis Pozo Ramos + +``` + +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3515162 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "rptree" +version = "0.3.0" +description = "Generate directory tree diagrams from the command line" +readme = "README.md" +license = { file = "LICENSE" } +authors = [ + { name = "Leodanis Pozo Ramos" } +] +requires-python = ">=3.8" +dependencies = [ + "pathspec" +] + +[project.scripts] +rptree = "rptree.__main__:main" + +[project.urls] +Homepage = "https://github.com/realpython/rptree" \ No newline at end of file diff --git a/rptree/__init__.py b/rptree/__init__.py index 6d8c007..129eb4a 100644 --- a/rptree/__init__.py +++ b/rptree/__init__.py @@ -1,3 +1,5 @@ """Top-level package for RP Tree.""" -__version__ = "0.2.0" +__all__ = ["__version__"] + +__version__ = "0.2.0" \ No newline at end of file diff --git a/rptree/__main__.py b/rptree/__main__.py index 56415f1..1a468ff 100644 --- a/rptree/__main__.py +++ b/rptree/__main__.py @@ -12,26 +12,36 @@ def main() -> None: root_dir = pathlib.Path(args.root_dir).resolve() if not root_dir.exists(): - print(f"Directory not found: {root_dir}") + print(f"Error: directory not found: {root_dir}", file=sys.stderr) sys.exit(1) if not root_dir.is_dir(): - print(f"Path is not a directory: {root_dir}") + print(f"Error: path is not a directory: {root_dir}", file=sys.stderr) sys.exit(1) - tree = DirectoryTree( - root_dir=root_dir, - dir_only=args.dir_only, - files_only=args.files_only, - dirs_first=args.dirs_first, - files_first=args.files_first, - ignore=args.ignore, - use_gitignore=args.gitignore, - depth_level=args.depth_level, - output_file=args.output_file, - ) - - tree.generate() + try: + tree = DirectoryTree( + root_dir=root_dir, + dir_only=args.dir_only, + files_only=args.files_only, + dirs_first=args.dirs_first, + files_first=args.files_first, + no_pipes=args.no_pipes, + ignore=args.ignore, + use_gitignore=args.gitignore, + depth_level=args.depth_level, + output_file=args.output_file, + ) + + tree.generate() + + except KeyboardInterrupt: + print("\nOperation cancelled.", file=sys.stderr) + sys.exit(130) + + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) if __name__ == "__main__": diff --git a/rptree/cli.py b/rptree/cli.py index 37c27e4..a761bd5 100644 --- a/rptree/cli.py +++ b/rptree/cli.py @@ -10,42 +10,45 @@ def parse_cmd_line_arguments() -> argparse.Namespace: prog="rptree", description="Generate a directory tree", epilog="Thanks for using RP Tree!", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) - parser.version = f"RP Tree v{__version__}" - parser.add_argument("-v", "--version", action="version") + parser.add_argument( + "-v", + "--version", + action="version", + version=f"RP Tree v{__version__}", + ) parser.add_argument( "root_dir", metavar="ROOT_DIR", nargs="?", default=".", - help="generate a full directory tree starting at ROOT_DIR", + help="root directory to generate the tree from", ) - parser.add_argument( + mode = parser.add_mutually_exclusive_group() + mode.add_argument( "-d", "--dir-only", action="store_true", - help="generate a directory-only tree", + help="show directories only", ) - - parser.add_argument( + mode.add_argument( "-f", "--files-only", action="store_true", - help="generate a file-only tree", + help="show files only", ) ordering = parser.add_mutually_exclusive_group() - ordering.add_argument( "-df", "--dirs-first", action="store_true", help="list directories before files", ) - ordering.add_argument( "-ff", "--files-first", @@ -53,13 +56,20 @@ def parse_cmd_line_arguments() -> argparse.Namespace: help="list files before directories", ) + parser.add_argument( + "-n", + "--no-pipes", + action="store_true", + help="remove vertical pipes between branches", + ) + parser.add_argument( "-i", "--ignore", metavar="PATTERN", nargs="*", default=None, - help="ignore files or directories (supports patterns, e.g. *.py)", + help="ignore files or directories (e.g. *.py __pycache__)", ) parser.add_argument( @@ -74,28 +84,23 @@ def parse_cmd_line_arguments() -> argparse.Namespace: "--depth-level", metavar="N", type=int, - default=None, - help="limit tree depth to N levels", + help="limit tree depth (>= 0)", ) parser.add_argument( "-o", "--output-file", - metavar="OUTPUT_FILE", - default=None, - help="save the generated tree to a file", + metavar="FILE", + help="write output to file (markdown format)", ) args = parser.parse_args() - if args.dir_only and args.files_only: - parser.error("cannot use --dir-only and --files-only together") - - if args.dir_only and (args.dirs_first or args.files_first): - parser.error("ordering options are not valid with --dir-only") - - if args.files_only and (args.dirs_first or args.files_first): - parser.error("ordering options are not valid with --files-only") + # validações + if (args.dir_only or args.files_only) and ( + args.dirs_first or args.files_first + ): + parser.error("ordering options cannot be used with --dir-only or --files-only") if args.depth_level is not None and args.depth_level < 0: parser.error("--depth-level must be >= 0") diff --git a/rptree/rptree.py b/rptree/rptree.py index 2ddb000..ebb7f81 100644 --- a/rptree/rptree.py +++ b/rptree/rptree.py @@ -24,6 +24,7 @@ def __init__( files_only: bool = False, dirs_first: bool = False, files_first: bool = False, + no_pipes: bool = False, ignore: Optional[List[str]] = None, use_gitignore: bool = False, depth_level: Optional[int] = None, @@ -36,6 +37,7 @@ def __init__( files_only=files_only, dirs_first=dirs_first, files_first=files_first, + no_pipes=no_pipes, ignore=ignore, use_gitignore=use_gitignore, depth_level=depth_level, @@ -64,15 +66,17 @@ def __init__( files_only: bool, dirs_first: bool, files_first: bool, + no_pipes: bool, ignore: Optional[List[str]], use_gitignore: bool, depth_level: Optional[int], ) -> None: - self._root_dir = root_dir + self._root_dir = root_dir.resolve() self._dir_only = dir_only self._files_only = files_only self._dirs_first = dirs_first self._files_first = files_first + self._no_pipes = no_pipes self._ignore: Set[str] = set(ignore or []) self._depth_level = depth_level self._tree: Deque[str] = deque() @@ -82,47 +86,59 @@ def __init__( try: import pathspec - gitignore = root_dir / ".gitignore" + gitignore = self._root_dir / ".gitignore" if gitignore.exists(): patterns = gitignore.read_text().splitlines() self._gitignore = pathspec.PathSpec.from_lines( "gitwildmatch", patterns ) except ImportError: - print( - "Warning: pathspec not installed, .gitignore ignored", - file=sys.stderr, - ) + print("Warning: pathspec not installed", file=sys.stderr) def build_tree(self) -> Deque[str]: - self._tree.append(f"{self._root_dir.name}/") - self._tree_body(self._root_dir, depth=0) + root_name = self._root_dir.name or str(self._root_dir) + self._tree.append(f"{root_name}/") + self._tree_body(self._root_dir, prefix="", depth=0) return self._tree def _tree_body( self, directory: pathlib.Path, - prefix: str = "", - depth: int = 0, + prefix: str, + depth: int, ) -> None: if self._depth_level is not None and depth >= self._depth_level: return entries = self._prepare_entries(directory) + if not entries: + return + last_index = len(entries) - 1 for index, entry in enumerate(entries): - connector = ELBOW if index == last_index else TEE + is_last = index == last_index + connector = ELBOW if is_last else TEE + + self._tree.append( + f"{prefix}{connector} {entry.name}{'/' if entry.is_dir() else ''}" + ) if entry.is_dir(): - self._tree.append(f"{prefix}{connector} {entry.name}/") - extension = SPACE_PREFIX if index == last_index else PIPE_PREFIX - self._tree_body(entry, prefix + extension, depth + 1) - else: - self._tree.append(f"{prefix}{connector} {entry.name}") + # prefix base (igual tree clássico) + new_prefix = prefix + (SPACE_PREFIX if is_last else PIPE_PREFIX) + + self._tree_body(entry, new_prefix, depth + 1) + + # linha extra com pipe (SÓ no modo padrão) + if not self._no_pipes and not is_last: + self._tree.append(prefix + PIPE) def _prepare_entries(self, directory: pathlib.Path) -> List[pathlib.Path]: - entries = sorted(directory.iterdir(), key=lambda e: e.name.lower()) + try: + entries = sorted(directory.iterdir(), key=lambda e: e.name.lower()) + except PermissionError: + return [] entries = [e for e in entries if not self._is_ignored(e)] @@ -140,15 +156,23 @@ def _prepare_entries(self, directory: pathlib.Path) -> List[pathlib.Path]: return entries def _is_ignored(self, entry: pathlib.Path) -> bool: + # CLI ignore for pattern in self._ignore: if fnmatch(entry.name, pattern): return True + # .gitignore if self._gitignore: try: rel = entry.relative_to(self._root_dir) - if self._gitignore.match_file(str(rel)): + rel_str = str(rel) + + if self._gitignore.match_file(rel_str): + return True + + if entry.is_dir() and self._gitignore.match_file(rel_str + "/"): return True + except ValueError: pass diff --git a/setup.py b/setup.py deleted file mode 100644 index 87ae9eb..0000000 --- a/setup.py +++ /dev/null @@ -1,23 +0,0 @@ -from setuptools import setup, find_packages - -from rptree import __version__ - - -setup( - name="rptree", - version=__version__, - description="A directory tree generator for the command line", - author="Real Python", - license="MIT", - packages=find_packages(), - include_package_data=True, - install_requires=[ - "pathspec>=0.10", - ], - entry_points={ - "console_scripts": [ - "rptree=rptree.__main__:main", - ], - }, - python_requires=">=3.8", -) \ No newline at end of file From 2a0e556fcd3cb942949e2991fd3fc9adcdd841ce Mon Sep 17 00:00:00 2001 From: Davi RF Date: Wed, 18 Mar 2026 13:55:57 -0300 Subject: [PATCH 7/9] fix: add root pipe in default mode and correct tree rendering with depth-level --- rptree/rptree.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/rptree/rptree.py b/rptree/rptree.py index ebb7f81..8938da5 100644 --- a/rptree/rptree.py +++ b/rptree/rptree.py @@ -98,6 +98,12 @@ def __init__( def build_tree(self) -> Deque[str]: root_name = self._root_dir.name or str(self._root_dir) self._tree.append(f"{root_name}/") + + if not self._no_pipes: + entries = self._prepare_entries(self._root_dir) + if entries: + self._tree.append(PIPE) + self._tree_body(self._root_dir, prefix="", depth=0) return self._tree @@ -125,12 +131,9 @@ def _tree_body( ) if entry.is_dir(): - # prefix base (igual tree clássico) new_prefix = prefix + (SPACE_PREFIX if is_last else PIPE_PREFIX) - self._tree_body(entry, new_prefix, depth + 1) - # linha extra com pipe (SÓ no modo padrão) if not self._no_pipes and not is_last: self._tree.append(prefix + PIPE) @@ -156,12 +159,10 @@ def _prepare_entries(self, directory: pathlib.Path) -> List[pathlib.Path]: return entries def _is_ignored(self, entry: pathlib.Path) -> bool: - # CLI ignore for pattern in self._ignore: if fnmatch(entry.name, pattern): return True - # .gitignore if self._gitignore: try: rel = entry.relative_to(self._root_dir) From 086e4c30ec40edf5f9e57e8bb2b871eb53858279 Mon Sep 17 00:00:00 2001 From: Davi RF Date: Wed, 18 Mar 2026 14:00:45 -0300 Subject: [PATCH 8/9] readme updated: badges [python, license, version] --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 5889c5e..4baa407 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # RP Tree +![Python](https://img.shields.io/badge/python-3.8%2B-blue) +![License](https://img.shields.io/badge/license-MIT-green) +![Version](https://img.shields.io/badge/version-0.2.0-orange) + RP Tree is a simple and fast CLI tool to generate directory tree diagrams. ## Installation From 4572c0d295b678834514567fdf0d7cf95c733daf Mon Sep 17 00:00:00 2001 From: Davi Reis Furtado <151282787+davi-rf@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:12:23 -0300 Subject: [PATCH 9/9] readme updated: LICENSE link --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4baa407..e943983 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,6 @@ project/ Leodanis Pozo Ramos -``` +## License -``` +^RP Tree^ is distributed under the MIT license. See [LICENSE](LICENSE) for more information.