From 6daa87ada45f59a698d6e62b55494bcd16272afd Mon Sep 17 00:00:00 2001 From: Kasper Junge Date: Tue, 20 Aug 2024 14:31:22 +0200 Subject: [PATCH] switched to pathspec --- copcon/main.py | 70 +++++++++++++------------- poetry.lock | 19 +++++-- pyproject.toml | 3 +- tests/test_main.py | 121 ++++++++++++++++++++++++++++++++++----------- 4 files changed, 145 insertions(+), 68 deletions(-) diff --git a/copcon/main.py b/copcon/main.py index 2012c81..0e85537 100644 --- a/copcon/main.py +++ b/copcon/main.py @@ -5,7 +5,7 @@ import mimetypes import platform import sys -import fnmatch +import pathspec app = typer.Typer() @@ -23,7 +23,7 @@ ".vs", "bin", "obj", - "publish" + "publish", } # Default files to ignore @@ -35,47 +35,48 @@ "yarn.lock", } -def parse_copconignore(ignore_file: Path) -> List[str]: + +def parse_copconignore(ignore_file: Path) -> pathspec.PathSpec: """ - Parses the ignore patterns from a given .copconignore file. - + Parses the ignore patterns from a given .copconignore file using pathspec. + Args: ignore_file (Path): Path to the .copconignore file. - + Returns: - List[str]: A list of ignore patterns. + pathspec.PathSpec: A PathSpec object for pattern matching. """ if not ignore_file.exists(): - return [] + return pathspec.PathSpec.from_lines("gitwildmatch", []) + try: with ignore_file.open() as f: - return [line.strip() for line in f if line.strip() and not line.startswith('#')] + patterns = [ + line.strip() for line in f if line.strip() and not line.startswith("#") + ] + return pathspec.PathSpec.from_lines("gitwildmatch", patterns) except Exception as e: typer.echo(f"Error reading ignore file: {e}", err=True) - return [] + return pathspec.PathSpec.from_lines("gitwildmatch", []) + -def should_ignore(path: Path, ignore_patterns: List[str]) -> bool: +def should_ignore(path: Path, ignore_spec: pathspec.PathSpec) -> bool: """ Determines if a given path should be ignored based on the ignore patterns. - + Args: path (Path): The path to check. - ignore_patterns (List[str]): List of ignore patterns. - + ignore_spec (pathspec.PathSpec): PathSpec object with ignore patterns. + Returns: bool: True if the path should be ignored, False otherwise. """ + # Convert Path to string and add a trailing slash for directories path_str = str(path) - for pattern in ignore_patterns: - if pattern.endswith('/'): - if path_str.endswith('/'): - if fnmatch.fnmatch(path_str, pattern): - return True - elif fnmatch.fnmatch(path_str + '/', pattern): - return True - elif fnmatch.fnmatch(path_str, pattern): - return True - return False + if path.is_dir(): + path_str += "/" + return ignore_spec.match_file(path_str) + def generate_tree( directory: Path, @@ -83,7 +84,7 @@ def generate_tree( depth: int = -1, ignore_dirs: Set[str] = DEFAULT_IGNORE_DIRS, ignore_files: Set[str] = DEFAULT_IGNORE_FILES, - ignore_patterns: List[str] = [], + ignore_spec: pathspec.PathSpec = pathspec.PathSpec([]), root: Optional[Path] = None, ) -> str: if depth == 0: @@ -104,14 +105,14 @@ def generate_tree( continue if path.is_file() and path.name in ignore_files: continue - if should_ignore(relative_path, ignore_patterns): + if should_ignore(relative_path, ignore_spec): continue visible_contents.append(path) for i, path in enumerate(visible_contents): is_last = i == len(visible_contents) - 1 current_prefix = "└── " if is_last else "├── " - + if path.is_dir(): subtree_prefix = " " if is_last else "│ " subtree = generate_tree( @@ -120,7 +121,7 @@ def generate_tree( depth - 1 if depth > 0 else -1, ignore_dirs, ignore_files, - ignore_patterns, + ignore_spec, root, ) if subtree: # Only include non-empty directories @@ -131,6 +132,7 @@ def generate_tree( return "\n".join(output) + def get_file_content(file_path: Path) -> str: try: mime_type, _ = mimetypes.guess_type(str(file_path)) @@ -202,13 +204,13 @@ def main( if ignore_files: files_to_ignore.update(ignore_files) - ignore_patterns = [] + ignore_spec = pathspec.PathSpec([]) if copconignore: - ignore_patterns = parse_copconignore(copconignore) + ignore_spec = parse_copconignore(copconignore) else: default_copconignore = directory / ".copconignore" if default_copconignore.exists(): - ignore_patterns = parse_copconignore(default_copconignore) + ignore_spec = parse_copconignore(default_copconignore) output = [] output.append("Directory Structure:") @@ -219,7 +221,7 @@ def main( depth=depth, ignore_dirs=dirs_to_ignore, ignore_files=files_to_ignore, - ignore_patterns=ignore_patterns, + ignore_spec=ignore_spec, ) ) output.append("\nFile Contents:") @@ -235,9 +237,9 @@ def main( continue if file_path.name in files_to_ignore: continue - if should_ignore(file_path, ignore_patterns): - continue relative_path = file_path.relative_to(directory) + if should_ignore(relative_path, ignore_spec): + continue output.append(f"\nFile: {relative_path}") output.append("-" * 40) output.append(get_file_content(file_path)) diff --git a/poetry.lock b/poetry.lock index 913ce3c..d878d42 100644 --- a/poetry.lock +++ b/poetry.lock @@ -82,6 +82,17 @@ files = [ {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + [[package]] name = "pluggy" version = "1.5.0" @@ -181,13 +192,13 @@ files = [ [[package]] name = "typer" -version = "0.12.3" +version = "0.12.4" description = "Typer, build great CLIs. Easy to code. Based on Python type hints." optional = false python-versions = ">=3.7" files = [ - {file = "typer-0.12.3-py3-none-any.whl", hash = "sha256:070d7ca53f785acbccba8e7d28b08dcd88f79f1fbda035ade0aecec71ca5c914"}, - {file = "typer-0.12.3.tar.gz", hash = "sha256:49e73131481d804288ef62598d97a1ceef3058905aa536a1134f90891ba35482"}, + {file = "typer-0.12.4-py3-none-any.whl", hash = "sha256:819aa03699f438397e876aa12b0d63766864ecba1b579092cc9fe35d886e34b6"}, + {file = "typer-0.12.4.tar.gz", hash = "sha256:c9c1613ed6a166162705b3347b8d10b661ccc5d95692654d0fb628118f2c34e6"}, ] [package.dependencies] @@ -210,4 +221,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "9274e030564716aeeadc17217e9853b53d91ea9870de89a51b1f35eb46e89743" +content-hash = "ad198c268f47bb872f2437b4ffa5cfa8cf3f3a84ee39bc7ef615e7aefd520b74" diff --git a/pyproject.toml b/pyproject.toml index cef1b27..e5c1405 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "copcon" -version = "0.2.4" +version = "0.2.5" description = "" authors = ["Kasper Junge "] readme = "README.md" @@ -9,6 +9,7 @@ readme = "README.md" python = "^3.11" typer = "^0.12.3" pywin32 = {version = "^300", platform = "win32"} +pathspec = "^0.12.1" [tool.poetry.scripts] copcon = "copcon:copcon_app" diff --git a/tests/test_main.py b/tests/test_main.py index 424b560..76f8d63 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -2,10 +2,18 @@ from typer.testing import CliRunner from pathlib import Path import shutil -from copcon.main import app, generate_tree, get_file_content, parse_copconignore, should_ignore +import pathspec +from copcon.main import ( + app, + generate_tree, + get_file_content, + parse_copconignore, + should_ignore, +) runner = CliRunner() + @pytest.fixture def temp_dir(tmp_path): # Create a temporary directory structure @@ -19,27 +27,37 @@ def temp_dir(tmp_path): # Clean up after the test shutil.rmtree(tmp_path) + @pytest.fixture def copconignore_file(temp_dir): ignore_file = temp_dir / ".copconignore" ignore_file.write_text("*.log\ntemp/\n**/*.tmp") return ignore_file + def test_generate_tree(temp_dir): # Test the generate_tree function tree = generate_tree(temp_dir) print("Generated tree:") print(tree) - + tree_lines = set(tree.strip().split("\n")) # Check for the presence of expected elements - assert any("file1.txt" in line for line in tree_lines), "file1.txt not found in tree" + assert any( + "file1.txt" in line for line in tree_lines + ), "file1.txt not found in tree" assert any("subdir" in line for line in tree_lines), "subdir not found in tree" - assert any("file2.txt" in line for line in tree_lines), "file2.txt not found in tree" - assert any("file3.log" in line for line in tree_lines), "file3.log not found in tree" + assert any( + "file2.txt" in line for line in tree_lines + ), "file2.txt not found in tree" + assert any( + "file3.log" in line for line in tree_lines + ), "file3.log not found in tree" assert any("temp" in line for line in tree_lines), "temp not found in tree" - assert any("temp_file.tmp" in line for line in tree_lines), "temp_file.tmp not found in tree" + assert any( + "temp_file.tmp" in line for line in tree_lines + ), "temp_file.tmp not found in tree" # Check the total number of lines assert len(tree_lines) == 6, f"Expected 6 lines, got {len(tree_lines)}" @@ -50,6 +68,7 @@ def test_generate_tree(temp_dir): assert "│ └── file3.log" in tree_lines, "Expected '│ └── file3.log' in tree" assert "└── file1.txt" in tree_lines, "Expected '└── file1.txt' in tree" + def test_get_file_content(temp_dir): # Test with a text file text_file = temp_dir / "text_file.txt" @@ -63,36 +82,62 @@ def test_get_file_content(temp_dir): assert "[Binary file]" in content assert "Size: 4 bytes" in content + def test_should_ignore(): patterns = ["*.log", "temp/", "**/*.tmp"] - assert should_ignore(Path("file.log"), patterns) == True, "should ignore .log files" - assert should_ignore(Path("temp"), patterns) == True, "should ignore 'temp' directory" - assert should_ignore(Path("temp/"), patterns) == True, "should ignore 'temp/' directory" - assert should_ignore(Path("subdir/file.tmp"), patterns) == True, "should ignore .tmp files in subdirectories" - assert should_ignore(Path("file.txt"), patterns) == False, "should not ignore .txt files" - assert should_ignore(Path("subdir/file.txt"), patterns) == False, "should not ignore .txt files in subdirectories" + ignore_spec = pathspec.PathSpec.from_lines("gitwildmatch", patterns) + assert ( + should_ignore(Path("file.log"), ignore_spec) == True + ), "should ignore .log files" + assert ( + should_ignore(Path("subdir/file.tmp"), ignore_spec) == True + ), "should ignore .tmp files in subdirectories" + assert ( + should_ignore(Path("file.txt"), ignore_spec) == False + ), "should not ignore .txt files" + assert ( + should_ignore(Path("subdir/file.txt"), ignore_spec) == False + ), "should not ignore .txt files in subdirectories" def test_parse_copconignore(copconignore_file): - patterns = parse_copconignore(copconignore_file) - assert patterns == ["*.log", "temp/", "**/*.tmp"] + ignore_spec = parse_copconignore(copconignore_file) + assert isinstance(ignore_spec, pathspec.PathSpec) + assert len(ignore_spec.patterns) == 3 + assert any(p.pattern == "*.log" for p in ignore_spec.patterns) + assert any(p.pattern == "temp/" for p in ignore_spec.patterns) + assert any(p.pattern == "**/*.tmp" for p in ignore_spec.patterns) + def test_parse_copconignore_non_existent_file(tmp_path): non_existent_file = tmp_path / "non_existent_file" - patterns = parse_copconignore(non_existent_file) - assert patterns == [], "Expected empty list for non-existent file" + ignore_spec = parse_copconignore(non_existent_file) + assert isinstance(ignore_spec, pathspec.PathSpec) + assert len(ignore_spec.patterns) == 0 + def test_generate_tree_with_ignore_patterns(temp_dir, copconignore_file): - ignore_patterns = parse_copconignore(copconignore_file) - tree = generate_tree(temp_dir, ignore_patterns=ignore_patterns) + ignore_spec = parse_copconignore(copconignore_file) + tree = generate_tree(temp_dir, ignore_spec=ignore_spec) tree_lines = tree.strip().split("\n") - assert any("file1.txt" in line for line in tree_lines), "file1.txt should be in the tree" + assert any( + "file1.txt" in line for line in tree_lines + ), "file1.txt should be in the tree" assert any("subdir" in line for line in tree_lines), "subdir should be in the tree" - assert any("file2.txt" in line for line in tree_lines), "file2.txt should be in the tree" - assert not any("file3.log" in line for line in tree_lines), "file3.log should not be in the tree" - assert not any("temp" in line for line in tree_lines), "temp directory should not be in the tree" - assert not any("temp_file.tmp" in line for line in tree_lines), "temp_file.tmp should not be in the tree" + assert any( + "file2.txt" in line for line in tree_lines + ), "file2.txt should be in the tree" + assert not any( + "file3.log" in line for line in tree_lines + ), "file3.log should not be in the tree" + assert not any( + "temp" in line for line in tree_lines + ), "temp directory should not be in the tree" + assert not any( + "temp_file.tmp" in line for line in tree_lines + ), "temp_file.tmp should not be in the tree" + def test_main_command(temp_dir, monkeypatch): # Mock the copy_to_clipboard function @@ -115,18 +160,26 @@ def mock_copy_to_clipboard(text): # Check if copy_to_clipboard was called assert mock_called, "copy_to_clipboard was not called" + def test_main_command_with_copconignore(temp_dir, copconignore_file, monkeypatch): # Mock the copy_to_clipboard function mock_called = False + def mock_copy_to_clipboard(text): nonlocal mock_called mock_called = True print(f"Clipboard content:\n{text}") # Add this line for debugging assert "file1.txt" in text, "file1.txt should be in the clipboard content" assert "file2.txt" in text, "file2.txt should be in the clipboard content" - assert "file3.log" not in text, "file3.log should not be in the clipboard content" - assert "temp" not in text, "temp directory should not be in the clipboard content" - assert "temp_file.tmp" not in text, "temp_file.tmp should not be in the clipboard content" + assert ( + "file3.log" not in text + ), "file3.log should not be in the clipboard content" + assert ( + "temp" not in text + ), "temp directory should not be in the clipboard content" + assert ( + "temp_file.tmp" not in text + ), "temp_file.tmp should not be in the clipboard content" monkeypatch.setattr("copcon.main.copy_to_clipboard", mock_copy_to_clipboard) @@ -134,11 +187,15 @@ def mock_copy_to_clipboard(text): result = runner.invoke(app, [str(temp_dir)]) print(f"Command output:\n{result.output}") # Add this line for debugging assert result.exit_code == 0, f"Command failed with error: {result.output}" - assert "Directory structure and file contents have been copied to clipboard." in result.stdout + assert ( + "Directory structure and file contents have been copied to clipboard." + in result.stdout + ) # Check if copy_to_clipboard was called with the expected content assert mock_called, "copy_to_clipboard was not called" + def test_main_command_with_custom_copconignore(temp_dir, monkeypatch): # Create a custom .copconignore file custom_ignore_file = temp_dir / "custom_ignore" @@ -146,6 +203,7 @@ def test_main_command_with_custom_copconignore(temp_dir, monkeypatch): # Mock the copy_to_clipboard function mock_called = False + def mock_copy_to_clipboard(text): nonlocal mock_called mock_called = True @@ -158,9 +216,14 @@ def mock_copy_to_clipboard(text): monkeypatch.setattr("copcon.main.copy_to_clipboard", mock_copy_to_clipboard) # Run the CLI command with custom .copconignore file - result = runner.invoke(app, [str(temp_dir), "--copconignore", str(custom_ignore_file)]) + result = runner.invoke( + app, [str(temp_dir), "--copconignore", str(custom_ignore_file)] + ) assert result.exit_code == 0 - assert "Directory structure and file contents have been copied to clipboard." in result.stdout + assert ( + "Directory structure and file contents have been copied to clipboard." + in result.stdout + ) # Check if copy_to_clipboard was called with the expected content assert mock_called, "copy_to_clipboard was not called"