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 0613440..56415f1 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,32 @@ 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}") + sys.exit(1) + 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}") + sys.exit(1) + tree = DirectoryTree( - root_dir, dir_only=args.dir_only, output_file=args.output_file + 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() if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/rptree/cli.py b/rptree/cli.py index c12eb43..37c27e4 100644 --- a/rptree/cli.py +++ b/rptree/cli.py @@ -1,19 +1,20 @@ """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 +22,82 @@ 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( + "-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="PATTERN", + nargs="*", + default=None, + help="ignore files or directories (supports patterns, e.g. *.py)", + ) + + parser.add_argument( + "-gi", + "--gitignore", + action="store_true", + 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", metavar="OUTPUT_FILE", - nargs="?", - default=sys.stdout, - help="generate a full directory tree and save it to a file", + default=None, + help="save the generated tree to a file", ) - return parser.parse_args() + + 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") + + 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..2ddb000 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,140 @@ 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, + 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, + 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, + ignore: Optional[List[str]], + use_gitignore: bool, + depth_level: Optional[int], + ) -> None: + self._root_dir = root_dir self._dir_only = dir_only - self._tree = deque() + self._files_only = files_only + self._dirs_first = dirs_first + self._files_first = files_first + 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 - def build_tree(self): - self._tree_head() - self._tree_body(self._root_dir) + 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, + ) + + 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): - self._tree.append(f"{self._root_dir}{os.sep}") + 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, prefix=""): 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._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) + self._tree.append(f"{prefix}{connector} {entry.name}") + + def _prepare_entries(self, directory: pathlib.Path) -> List[pathlib.Path]: + entries = sorted(directory.iterdir(), key=lambda e: e.name.lower()) + + entries = [e for e in entries if not self._is_ignored(e)] - def _prepare_entries(self, directory): - entries = sorted( - directory.iterdir(), key=lambda entry: str(entry) - ) 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) + if self._gitignore.match_file(str(rel)): + 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 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