From 3c54f65a6e4d495b68f4d8c52147ddac142419c5 Mon Sep 17 00:00:00 2001 From: Markus Sagen Date: Sat, 12 Oct 2024 10:25:10 +0200 Subject: [PATCH] feat: refactor and add new commands --- CHANGELOG.md | 17 +++- README.md | 76 +++++++++++++----- src/rejx/cli.py | 196 ++++++++++++++++++++++++++++++++++----------- src/rejx/utils.py | 22 +++-- tests/test_rejx.py | 142 +++++++++++++++++++++++--------- 5 files changed, 342 insertions(+), 111 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d75c34..17ecde1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,17 +5,32 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## [0.1.0](https://github.com/MarkusSagen/rejx/pull/7) - 2024-10-12 +## [0.1.0](https://github.com/MarkusSagen/rejx/pull/9) - 2024-10-12 New release, fixes bugs and adds new options for selection ### Added +- New `tree` command as an alias for `ls --view tree` - Two new flags for targeting hidden files with `rejx`: - `--include-hidden` to include hidden files when searching - `--exclude-hidden` to exclude hidden files when searching - New flag, `--all`, to the fix and clean commands by @StephanLoor +### Changed + +- Refactored `find_rej_files` function to accept a path parameter +- Updated `fix`, `diff`, `ls`, and `clean` commands to use a consistent interface +- Enhanced README with more detailed usage examples +- Consistent `path` argument across all commands +- Improved error handling and user feedback +- The `--all` flag now works with a specified directory path + ### Fixed - Bug in previous release, making it not possible to install (See #2) +- Make consistent behaviour for the commands in rejx +- Improved handling of non `.rej` files in various commands +- Better error messages when invalid paths are provided +- Remove logs incorrectly suggesting that changes could be applied from non-rejx files +- Bug with duplicate logging when setting up rich logger diff --git a/README.md b/README.md index 5c80cfd..1187fb6 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,9 @@ It's important to use the commands cautiously, especially fix_all and clean, as This documentation provides a clear guide on how to interact with the rejx Typer application, making it easier for users to understand and utilize its functionalities. -## Setup +Screenshot 2024-10-12 at 10 08 31 + +## Installation ```shell # Pip @@ -26,47 +28,60 @@ poetry add rejx Your Python Typer application, rejx, provides a command line interface (CLI) for managing .rej files, which are typically generated when patches fail to apply cleanly. Below, I'll detail each command, its purpose, and how to use it, including optional arguments. +Most rejx commands support the flags: + +- `--all` - Recursively apply the command in subfolders +- `--preview` - Preview what a command would do before applying +- `--show-hidden` - Include hidden files (`.file`) when applying the command +- `--exclude-hidden` - Exclude hidden files from command + +For more details, rejx provides help for each commands + +```sh +rejx --help +rejx --help +``` + ### `fix` -Purpose: Applies the changes from one or more specified .rej file to their corresponding original file. -Usage: +Applies the changes from one or more specified `.rej` file to their corresponding original file. `rejx fix path/to/file1.rej path/to/file2.rej ...` -Passing the optional flag `--all` applies the changes from all .rej files to their corresponding original files. Usage: +Passing the optional flag `--all` applies the changes from all .rej files to their corresponding original files. + `rejx fix --all` ### `diff` -Purpose: Displays the differences between the current file(s) and the changes proposed in the corresponding .rej file(s). -Usage: +Displays the differences between the current file(s) and the changes proposed in the corresponding .rej file(s). `rejx diff ...` If no file name is passed, this displays the difference for all .rej files. -Note: This command uses a pager for output. Use arrow keys or Vim bindings to navigate, and q to quit. +[!NOTE] This command uses a pager for output. Use arrow keys or Vim bindings to navigate, and q to quit. ### `ls` -Purpose: Lists all .rej files in the current directory and subdirectories. By default, it lists files, but can also display them in a tree structure. -Usage: +Lists all .rej files in the current directory and subdirectories. By default, it lists files, but can also display them in a tree structure. For listing files: -`rejx ls` - -For tree view: +`rejx ls .` -`rejx ls --view tree` +The ls command supports different view modes. +Default view is a list of files, but there's also a tree view mode -For list view (default): +For tree view: -`rejx ls --view list` +`rejx ls . --view tree` +`rejx tree .` ### `clean` -Purpose: Deletes specified .rej files. It has an optional preview feature. -Usage: +Deletes specified .rej files. It has an optional preview feature. +The preview flag makes it possible to _preview_ which files would be staged for deletion before +applying it. `rejx clean path/to/file1.rej path/to/file2.rej ...` @@ -75,11 +90,30 @@ With preview: `rejx clean path/to/file1.rej path/to/file2.rej ... --preview` By passing the optional `--all` flag, this command deletes all the .rej files in the current directory and subdirectories. -Usage: -`rejx clean --all` + +`rejx clean . --all` This can be combined with the `--preview` option. -Usage: -`rejx clean --all --preview` + +`rejx clean . --all --preview` + +## Shell Completion + +To install shell completion for rejx commands + +```sh +rejx --install-completion && exec $SHELL +``` ______________________________________________________________________ + +## Dev + +For developers looking to apply changes or contribute changes, install the project with the just +command + +This will install the project, pre-commit and the dev dependencies + +```sh +just setup +``` diff --git a/src/rejx/cli.py b/src/rejx/cli.py index 4677f55..9a5c5c6 100644 --- a/src/rejx/cli.py +++ b/src/rejx/cli.py @@ -22,11 +22,11 @@ @app.command() def fix( - rej_files: list[str] = typer.Argument(default=None), + path: str = typer.Argument(default=".", help="Path to start searching for .rej files"), apply_to_all_files: Optional[bool] = typer.Option( False, "--all", - help="Apply changes from all .rej files.", + help="Apply changes to all .rej files in the specified path and its subdirectories.", show_default=False, ), exclude_hidden: Optional[bool] = typer.Option( @@ -37,11 +37,11 @@ def fix( ), ignore: Optional[list[str]] = typer.Option(None, "--ignore", help="Regex patterns to ignore directories"), ) -> None: - """Applies changes from a specified .rej file to its corresponding original file. + """Applies changes from specified .rej files to their corresponding original files. Args: ---- - rej_file (str): The path to the .rej file to be processed. + path (str): The path to start searching for .rej files. Defaults to current directory. apply_to_all_files (Optional[bool]): Determines whether all files should be fixed. Default: False. exclude_hidden (Optional[bool]): Determines whether to hide hidden files from output. Defaults to `False` ignore(Optional[List[str]]): List of regex patterns of directories to ignore rej files from. @@ -53,29 +53,49 @@ def fix( rejx fix path/to/file.rej ``` - To fix all files, run: + To fix all .rej files in the current directory and subdirectories, run: ```bash rejx fix --all ``` + + To fix all .rej files in a specific directory and its subdirectories, run: + ```bash + rejx fix path/to/directory --all + ``` """ - if apply_to_all_files: - for rej_file in rejx.utils.find_rej_files( + if pathlib.Path(path).is_file() and not apply_to_all_files: + rej_files = [path] + elif pathlib.Path(path).is_dir() and apply_to_all_files: + rej_files = rejx.utils.find_rej_files( + path=path, exclude_hidden=exclude_hidden, ignore=ignore, - ): - rejx.utils.process_rej_file(rej_file) + ) + else: + logger.error("Please specify a file or use --all with a directory path.") + raise typer.Exit(1) - elif rej_files is None: - logger.error("No file name specified") + if not rej_files: + logger.error("No .rej files specified or found") + return - else: - for rej_file in rej_files: - rejx.utils.process_rej_file(rej_file) + for rej_file in rej_files: + if not rej_file.endswith(".rej"): + logger.error(f"Skipping non-.rej file: {rej_file}") + continue + rejx.utils.process_rej_file(rej_file) +@app.command() @app.command() def diff( - rej_files: list[str] = typer.Argument(default=None), + path: str = typer.Argument(default=".", help="Path to start searching for .rej files"), + apply_to_all_files: Optional[bool] = typer.Option( + False, + "--all", + help="Display diff for all .rej files in the specified path and its subdirectories.", + show_default=False, + ), exclude_hidden: Optional[bool] = typer.Option( False, "--exclude-hidden", @@ -86,26 +106,36 @@ def diff( ) -> None: """Displays the diff of changes proposed by .rej files against their corresponding original files. - Displays the diff for all .rej files If no file names are specified. - Args: - ----- - rej_files (list[str]): Rej files to apply diff. + ---- + path (str): The path to start searching for .rej files. Defaults to current directory. + apply_to_all_files (Optional[bool]): Determines whether to show diff for all .rej files recursively. Default: False. exclude_hidden (Optional[bool]): Determines whether to hide hidden files from output. Defaults to `False` ignore(Optional[List[str]]): List of regex patterns of directories to ignore rej files from. Example: ------- - To display diffs for all .rej files, run: + To display diff for a specific .rej file: + ```bash + rejx diff path/to/file.rej + ``` + To display diff for all .rej files in the current directory and subdirectories: ```bash - rejx diff + rejx diff --all ``` """ - if rej_files is None: + if pathlib.Path(path).is_file() and not apply_to_all_files: + rej_files = [path] + elif pathlib.Path(path).is_dir(): rej_files = rejx.utils.find_rej_files( + path=path, exclude_hidden=exclude_hidden, ignore=ignore, + recursive=apply_to_all_files, ) + else: + logger.error("Please specify a valid file or directory path.") + raise typer.Exit(1) console = Console() file_logs = [] @@ -143,7 +173,9 @@ def diff( ) for logs in file_logs: - console.rule(f"\n{logs['filename']}\n", align="left") + filename = logs["filename"] + + console.rule(f"\n{filename}\n", align="left") console.print( Syntax( logs["diff"], @@ -156,8 +188,16 @@ def diff( ) +@app.command() @app.command() def ls( + path: str = typer.Argument(default=".", help="Path to start searching for .rej files"), + apply_to_all_files: Optional[bool] = typer.Option( + False, + "--all", + help="List all .rej files in the specified path and its subdirectories.", + show_default=False, + ), exclude_hidden: Optional[bool] = typer.Option( False, "--exclude-hidden", @@ -167,31 +207,46 @@ def ls( ignore: Optional[list[str]] = typer.Option(None, "--ignore", help="Regex patterns to ignore directories"), view: Optional[str] = typer.Option("list", help="View as 'list' or 'tree'"), ) -> None: - """Lists all .rej files in the current directory and subdirectories. + """Lists .rej files in the specified path. Supports different view formats. Args: ---- + path (str): The path to start searching for .rej files. Defaults to current directory. + apply_to_all_files (Optional[bool]): Determines whether to list all .rej files recursively. Default: False. exclude_hidden (Optional[bool]): Determines whether to hide hidden files from output. Defaults to `False` ignore(Optional[List[str]]): List of regex patterns of directories to ignore rej files from. view (Optional[str]): The view format. Can be 'list' or 'tree'. Defaults to 'list'. Example: ------- - - To list .rej files in list format, run: + - To list .rej files in the current directory: ```bash rejx ls ``` - - To display .rej files in a tree structure, run: + - To list all .rej files recursively: + ```bash + rejx ls --all + ``` + - To list .rej files in a specific directory: ```bash - rejx ls --view tree + rejx ls path/to/directory ``` """ - rej_files = rejx.utils.find_rej_files( - exclude_hidden=exclude_hidden, - ignore=ignore, - ) + if pathlib.Path(path).is_file() and not apply_to_all_files: + rej_files = [path] + elif pathlib.Path(path).is_dir(): + rej_files = rejx.utils.find_rej_files( + path=path, + exclude_hidden=exclude_hidden, + ignore=ignore, + recursive=apply_to_all_files, + ) + else: + logger.error("Please specify a valid directory path.") + raise typer.Exit(1) + console = Console() if not rej_files: @@ -209,13 +264,59 @@ def ls( console.print("[bold]Usage: --view list|tree [/bold]") +@app.command() +def tree( + path: str = typer.Argument(default=".", help="Path to start searching for .rej files"), + apply_to_all_files: Optional[bool] = typer.Option( + True, + "--all", + help="List all .rej files in the specified path and its subdirectories.", + show_default=False, + ), + exclude_hidden: Optional[bool] = typer.Option( + False, + "--exclude-hidden", + help="Hide hidden files.", + show_default=False, + ), + ignore: Optional[list[str]] = typer.Option(None, "--ignore", help="Regex patterns to ignore directories"), +) -> None: + """Displays .rej files in a tree structure. + + This is an alias for `rejx ls --view tree`. + + Args: + ---- + path (str): The path to start searching for .rej files. Defaults to current directory. + apply_to_all_files (Optional[bool]): Determines whether to list all .rej files recursively. Default: True. + exclude_hidden (Optional[bool]): Determines whether to hide hidden files from output. Defaults to `False` + ignore(Optional[List[str]]): List of regex patterns of directories to ignore rej files from. + + Example: + ------- + To display .rej files in a tree structure: + ```bash + rejx tree + ``` + """ + rej_files = rejx.utils.find_rej_files( + path=path, + exclude_hidden=exclude_hidden, + ignore=ignore, + recursive=apply_to_all_files, + ) + tree = rejx.utils.build_file_tree(rej_files) + console = Console() + console.print(tree) + + @app.command() def clean( - rej_files: list[str] = typer.Argument(default=None), + path: str = typer.Argument(default=".", help="Path to start searching for .rej files"), apply_to_all_files: Optional[bool] = typer.Option( False, "--all", - help="Apply changes from all .rej files.", + help="Delete all .rej files in the specified path and its subdirectories.", show_default=False, ), preview: bool = typer.Option( @@ -230,13 +331,13 @@ def clean( ), ignore: Optional[list[str]] = typer.Option(None, "--ignore", help="Regex patterns to ignore directories"), ) -> None: - """Deletes one or all .rej files in the current directory and subdirectories. + """Deletes .rej files in the specified path. Optional preview before deletion. Args: ---- - rej_files (Optional, List[str]): a list of names of files to be deleted. + path (str): The path to start searching for .rej files. Defaults to current directory. apply_to_all_files (Optional, bool): determines if all files should be removed. Defaults to "False". preview (bool): If True, previews the files before deleting. Defaults to False. exclude_hidden (Optional[bool]): Determines whether to hide hidden files from output. Defaults to `False` @@ -244,27 +345,30 @@ def clean( Example: ------- - - To delete a file file.txt.rej without preview, run: - ```bash - rejx clean file.txt.rej - ``` - - To delete all .rej files without preview, run: + - To delete a specific .rej file without preview: + ```bash + rejx clean path/to/file.txt.rej + ``` + - To delete all .rej files in the current directory and subdirectories: ```bash rejx clean --all ``` - - To preview files before deletion, run: + - To preview files before deletion in a specific directory: ```bash - rejx clean --all --preview + rejx clean path/to/directory --all --preview ``` """ - if apply_to_all_files: + if pathlib.Path(path).is_file() and not apply_to_all_files: + rej_files = [path] + elif pathlib.Path(path).is_dir() and apply_to_all_files: rej_files = rejx.utils.find_rej_files( + path=path, exclude_hidden=exclude_hidden, ignore=ignore, ) - - elif rej_files is None: - logger.error("No filename specified.") + else: + logger.error("Please specify a file or use --all with a directory path.") + raise typer.Exit(1) console = Console() diff --git a/src/rejx/utils.py b/src/rejx/utils.py index 43f6638..4c4d265 100644 --- a/src/rejx/utils.py +++ b/src/rejx/utils.py @@ -14,16 +14,20 @@ def find_rej_files( + path: str = ".", *, exclude_hidden: bool | None = True, ignore: Optional[list[str]] = None, + recursive: bool = True, ) -> list[str]: - """Finds all .rej files in the current directory and its subdirectories. + """Finds all .rej files in the specified path and its subdirectories. Args: ---- - exclude_hidden (bool): Include hidden files. + path (str): The path to start searching for .rej files. Defaults to current directory. + exclude_hidden (bool): Exclude hidden files. ignore(Optional[List[str]]): List of regex patterns of directories to ignore rej files from. + recursive (bool): Whether to search recursively in subdirectories. Defaults to True. Returns: ------- @@ -32,13 +36,15 @@ def find_rej_files( Example: ------- ```python - rej_files = find_rej_files() + rej_files = find_rej_files("path/to/directory", recursive=True) print(rej_files) # Prints paths to all .rej files. ``` """ - rej_file_matches = glob.glob("**/*.rej", recursive=True) + pattern = "**/*.rej" if recursive else "*.rej" + rej_file_matches = glob.glob(os.path.join(path, pattern), recursive=recursive) if not exclude_hidden: - rej_file_matches += glob.glob("**/.*.rej", recursive=True) + hidden_pattern = "**/.*.rej" if recursive else ".*.rej" + rej_file_matches += glob.glob(os.path.join(path, hidden_pattern), recursive=recursive) filtered_files = [f for f in rej_file_matches if not any(re.search(pattern, f) for pattern in ignore or [])] @@ -168,6 +174,10 @@ def process_rej_file(rej_file_path: str) -> bool: """ success = False + if not rej_file_path.endswith(".rej"): + logger.error(f"Invalid file type: {rej_file_path}. Only .rej files are supported.") + return success + try: target_file_path = rej_file_path.replace(".rej", "") rej_lines = parse_rej_file(rej_file_path) @@ -215,7 +225,7 @@ def build_file_tree(rej_files: list) -> Tree: ``` """ tree = Tree( - ":open_file_folder: Rejected Files Tree", + "[bold bright_blue].", guide_style="bold bright_blue", ) node_dict = {} diff --git a/tests/test_rejx.py b/tests/test_rejx.py index 7bc0b44..8d7f71a 100644 --- a/tests/test_rejx.py +++ b/tests/test_rejx.py @@ -1,11 +1,13 @@ """Unittest for rejx.""" import os from pathlib import Path +from unittest.mock import patch import pytest from typer.testing import CliRunner import rejx.utils +import rejx.cli from rejx import app # Constants @@ -32,6 +34,13 @@ def sample_target_file(): return str(TEST_DATA_DIR / "sample.txt") +@pytest.fixture +def non_rej_file(tmp_path): + file = tmp_path / "test.txt" + file.write_text("This is a test file") + return str(file) + + @pytest.fixture(autouse=True) def _setup_sample_rej_file() -> None: """Fixture to ensure the sample.txt.rej file is present and properly formatted for each test.""" @@ -80,41 +89,100 @@ def test_process_rej_file(sample_rej_file: str): # REJX COMMANDS # # # ##################################### -def test_fix(sample_rej_file: str): - result = runner.invoke(app, ["fix", sample_rej_file]) - assert result.exit_code == 0 - - -def test_fix_all(): - result = runner.invoke(app, ["fix", "--all"]) - assert result.exit_code == 0 - - -def test_ls(): - result = runner.invoke(app, ["ls"]) - assert result.exit_code == 0 - - -def test_ls_list(): - result = runner.invoke(app, ["ls", "--view", "list"]) - assert result.exit_code == 0 - - -def test_ls_tree(): - result = runner.invoke(app, ["ls", "--view", "tree"]) - assert result.exit_code == 0 - - -def test_clean(): - result = runner.invoke(app, ["clean", "--all"]) - assert result.exit_code == 0 - - -def test_clean_with_preview(): - result = runner.invoke(app, ["clean", "all", "--preview"], input="y\n") - assert result.exit_code == 0 - -def test_diff(): - result = runner.invoke(app, ["diff"]) - assert result.exit_code == 0 +class TestFix: + def test_fix(self, sample_rej_file: str): + result = runner.invoke(app, ["fix", sample_rej_file]) + assert result.exit_code == 0 + + def test_fix_all(self, tmp_path): + rej_file = tmp_path / "test.rej" + rej_file.write_text("Sample content") + result = runner.invoke(app, ["fix", str(tmp_path), "--all"]) + assert result.exit_code == 0 + + def test_fix_invalid_input(self): + result = runner.invoke(app, ["fix", "non_existent_file.rej"]) + assert result.exit_code == 1 + +class TestLs: + def test_ls(self, tmp_path): + rej_file = tmp_path / "test.rej" + rej_file.write_text("Sample content") + result = runner.invoke(app, ["ls", str(tmp_path)]) + assert result.exit_code == 0 + assert "test.rej" in result.output + + def test_ls_all(self, tmp_path): + subdir = tmp_path / "subdir" + subdir.mkdir() + rej_file = subdir / "test.rej" + rej_file.write_text("Sample content") + result = runner.invoke(app, ["ls", str(tmp_path), "--all"]) + assert result.exit_code == 0 + assert "test.rej" in result.output + + def test_ls_tree(self, tmp_path): + subdir = tmp_path / "subdir" + subdir.mkdir() + rej_file = subdir / "test.rej" + rej_file.write_text("Sample content") + result = runner.invoke(app, ["ls", str(tmp_path), "--all", "--view", "tree"]) + assert result.exit_code == 0 + assert "test.rej" in result.output + +class TestClean: + def test_clean(self, tmp_path): + rej_file = tmp_path / "test.rej" + rej_file.write_text("Sample content") + result = runner.invoke(app, ["clean", str(rej_file)]) + assert result.exit_code == 0 + assert not rej_file.exists() + + def test_clean_all(self, tmp_path): + rej_file1 = tmp_path / "test1.rej" + rej_file2 = tmp_path / "test2.rej" + rej_file1.write_text("Sample content") + rej_file2.write_text("Sample content") + result = runner.invoke(app, ["clean", str(tmp_path), "--all"], input="y\n") + assert result.exit_code == 0 + assert not rej_file1.exists() + assert not rej_file2.exists() + + def test_clean_with_preview(self, tmp_path): + rej_file = tmp_path / "test.rej" + rej_file.write_text("Sample content") + result = runner.invoke(app, ["clean", str(tmp_path), "--all", "--preview"], input="y\n") + assert result.exit_code == 0 + assert "test.rej" in result.output + assert not rej_file.exists() + + +class TestDiff: + def test_diff(self): + result = runner.invoke(app, ["diff"]) + assert result.exit_code == 0 + + +class TestNonRejFile: + def test_process_rej_file_with_non_rej_file(self, non_rej_file): + result = rejx.utils.process_rej_file(non_rej_file) + assert result is False + + @patch('rejx.utils.process_rej_file') + def test_fix_with_non_rej_file(self, mock_process_rej_file, non_rej_file): + runner = CliRunner() + result = runner.invoke(app, ["fix", non_rej_file]) + assert result.exit_code == 0 + mock_process_rej_file.assert_not_called() + + @patch('rejx.utils.find_rej_files') + @patch('rejx.utils.process_rej_file') + def test_fix_all_with_mixed_files(self, mock_process_rej_file, mock_find_rej_files, non_rej_file): + mock_find_rej_files.return_value = ["file1.rej", non_rej_file, "file2.rej"] + runner = CliRunner() + result = runner.invoke(app, ["fix", "--all"]) + assert result.exit_code == 0 + assert mock_process_rej_file.call_count == 2 + mock_process_rej_file.assert_any_call("file1.rej") + mock_process_rej_file.assert_any_call("file2.rej")