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 f647053..e943983 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,182 @@ # RP Tree -RP Tree is a command-line tool to generate directory tree diagrams. +![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) -## Installation +RP Tree is a simple and fast CLI tool to generate directory tree diagrams. -To install **RP Tree**, just run the following command: +## Installation -```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 version + +### Modes + +- `-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 + +> Alphabetical order is always applied as base sorting. + +### Display + +- `-n`, `--no-pipes` Remove vertical pipes between branches + +### Ignoring + +- `-i`, `--ignore PATTERN [PATTERN ...]` Ignore files/directories +- `-gi`, `--gitignore` Respect `.gitignore` rules + +### Depth + +- `-dl`, `--depth-level N` Limit depth + +### Output + +- `-o`, `--output-file FILE` Save output to file (Markdown format) + +## Examples + +Basic: + +```bash +rptree ``` -**Note:** The `-h` or `--help` option provides help on using RP Tree. +Directories first: + +```bash +rptree . -df +``` + +Files only: + +```bash +rptree . -f +``` + +Limit depth: + +```bash +rptree . -dl 2 +``` + +Ignore entries: + +```bash +rptree . -i node_modules dist .git +``` + +Use `.gitignore`: + +```bash +rptree . -gi +``` + +No pipes mode: + +```bash +rptree . -n +``` + +Save to file: + +```bash +rptree . -o tree.md +``` ## Sample Output -```sh -$ rptree hello/ -./hello/ +### Default + +```text +project/ │ -├── hello/ -│ ├── __init__.py -│ └── hello.py +├── src/ +│ ├── main.py +│ └── utils.py │ ├── tests/ -│ └── test_hello.py +│ └── test_main.py │ -├── LICENSE -├── README.md -├── requirements.txt -└── setup.py +└── README.md ``` -That's it! You've generated a nice directory tree diagram. +### No pipes (`-n`) + +```text +project/ +├── src/ +│ ├── main.py +│ └── utils.py +├── tests/ +│ └── test_main.py +└── README.md +``` ## Features -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. +- Clean and readable tree output +- `.gitignore` support +- Custom ignore patterns +- Depth limiting +- Flexible sorting +- Optional compact mode (`--no-pipes`) -RP Tree also provides the following options: +## Release History -- `-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 +### 0.3.0 -## Release History +- Fixed `--no-pipes` behavior +- Fixed `.gitignore` directory handling +- Improved tree formatting (matches classic `tree`) +- Added modern packaging (`pyproject.toml`) + +### 0.2.0 + +- Added filtering and ordering options +- Added `.gitignore` support +- Added depth control + +### 0.1.0 + +- Initial release + +## Author -- 0.1.1 - - Display the entries in alphabetical order -- 0.1.0 - - A work in progress +Leodanis Pozo Ramos -## About the Author +## License -Leodanis Pozo Ramos - Email: leodanis@realpython.com +^RP Tree^ is distributed under the MIT license. See [LICENSE](LICENSE) for more information. 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 e8d5a69..129eb4a 100644 --- a/rptree/__init__.py +++ b/rptree/__init__.py @@ -1,3 +1,5 @@ """Top-level package for RP Tree.""" -__version__ = "0.1.1" +__all__ = ["__version__"] + +__version__ = "0.2.0" \ No newline at end of file diff --git a/rptree/__main__.py b/rptree/__main__.py index 0613440..1a468ff 100644 --- a/rptree/__main__.py +++ b/rptree/__main__.py @@ -1,4 +1,4 @@ -"""This module provides the RP Tree CLI.""" +"""Entry point for RP Tree CLI.""" import pathlib import sys @@ -7,17 +7,42 @@ 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"Error: directory not found: {root_dir}", file=sys.stderr) + sys.exit(1) + if not root_dir.is_dir(): - print("The specified root directory doesn't exist") - sys.exit() - tree = DirectoryTree( - root_dir, dir_only=args.dir_only, output_file=args.output_file - ) - tree.generate() + print(f"Error: path is not a directory: {root_dir}", file=sys.stderr) + sys.exit(1) + + 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__": - main() + main() \ No newline at end of file diff --git a/rptree/cli.py b/rptree/cli.py index c12eb43..a761bd5 100644 --- a/rptree/cli.py +++ b/rptree/cli.py @@ -1,38 +1,108 @@ """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!", + 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", + ) + mode.add_argument( + "-f", + "--files-only", + action="store_true", + 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", + action="store_true", + 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 (e.g. *.py __pycache__)", + ) + + parser.add_argument( + "-gi", + "--gitignore", + action="store_true", + help="respect .gitignore rules", + ) + + parser.add_argument( + "-dl", + "--depth-level", + metavar="N", + type=int, + help="limit tree depth (>= 0)", + ) + parser.add_argument( "-o", "--output-file", - metavar="OUTPUT_FILE", - nargs="?", - default=sys.stdout, - help="generate a full directory tree and save it to a file", + metavar="FILE", + help="write output to file (markdown format)", ) - return parser.parse_args() + + args = parser.parse_args() + + # 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") + + return args \ No newline at end of file diff --git a/rptree/rptree.py b/rptree/rptree.py index 2581c0a..8938da5 100644 --- a/rptree/rptree.py +++ b/rptree/rptree.py @@ -1,9 +1,12 @@ """This module provides RP Tree main module.""" -import os +from __future__ import annotations + import pathlib import sys from collections import deque +from fnmatch import fnmatch +from typing import Deque, List, Optional, Set PIPE = "│" ELBOW = "└──" @@ -13,74 +16,165 @@ class DirectoryTree: - def __init__(self, root_dir, dir_only=False, output_file=sys.stdout): + def __init__( + self, + root_dir: pathlib.Path, + *, + dir_only: bool = False, + 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, + output_file: Optional[str] = None, + ) -> None: self._output_file = output_file - self._generator = _TreeGenerator(root_dir, dir_only) + self._generator = _TreeGenerator( + root_dir=root_dir, + dir_only=dir_only, + 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, + ) - def generate(self): + def generate(self) -> None: tree = self._generator.build_tree() - if self._output_file != sys.stdout: - # Wrap the tree in a markdown code block + + if self._output_file: tree.appendleft("```") tree.append("```") - self._output_file = open( - self._output_file, mode="w", encoding="UTF-8" - ) - with self._output_file as stream: - for entry in tree: - print(entry, file=stream) + with open(self._output_file, "w", encoding="utf-8") as f: + for line in tree: + print(line, file=f) + else: + for line in tree: + print(line) class _TreeGenerator: - def __init__(self, root_dir, dir_only=False): - self._root_dir = pathlib.Path(root_dir) + def __init__( + self, + *, + root_dir: pathlib.Path, + dir_only: bool, + 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.resolve() self._dir_only = dir_only - self._tree = deque() + 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() + + self._gitignore = None + if use_gitignore: + try: + import pathspec + + 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", file=sys.stderr) + + def build_tree(self) -> Deque[str]: + root_name = self._root_dir.name or str(self._root_dir) + self._tree.append(f"{root_name}/") - def build_tree(self): - self._tree_head() - self._tree_body(self._root_dir) + 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 - def _tree_head(self): - self._tree.append(f"{self._root_dir}{os.sep}") + def _tree_body( + self, + directory: pathlib.Path, + prefix: str, + depth: int, + ) -> None: + if self._depth_level is not None and depth >= self._depth_level: + return - def _tree_body(self, directory, prefix=""): 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(): - if index == 0: + new_prefix = prefix + (SPACE_PREFIX if is_last else PIPE_PREFIX) + self._tree_body(entry, new_prefix, depth + 1) + + if not self._no_pipes and not is_last: self._tree.append(prefix + PIPE) - 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]: + 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)] + if self._dir_only: - return [entry for entry in entries if entry.is_dir()] - return sorted(entries, key=lambda entry: entry.is_file()) - - def _add_directory( - self, directory, index, last_index, prefix, connector - ): - self._tree.append(f"{prefix}{connector} {directory.name}{os.sep}") - if index != last_index: - prefix += PIPE_PREFIX - else: - prefix += SPACE_PREFIX - self._tree_body( - directory=directory, - prefix=prefix, - ) - if prefix := prefix.rstrip(): - self._tree.append(prefix) + return [e for e in entries if e.is_dir()] + + if self._files_only: + return [e for e in entries if e.is_file()] + + if self._dirs_first: + 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 _is_ignored(self, entry: pathlib.Path) -> bool: + for pattern in self._ignore: + if fnmatch(entry.name, pattern): + return True + + if self._gitignore: + try: + rel = entry.relative_to(self._root_dir) + 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 - def _add_file(self, file, prefix, connector): - self._tree.append(f"{prefix}{connector} {file.name}") + return False \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 3ddee53..0000000 --- a/setup.py +++ /dev/null @@ -1,38 +0,0 @@ -import pathlib -from setuptools import setup - -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", - 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"], - include_package_data=True, - entry_points={ - "console_scripts": [ - "rptree=rptree.__main__:main", - ] - }, -)