diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1780c32..4707bdc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,5 +43,4 @@ repos: language: python files: '' pass_filenames: false - args: [".", "-c", '.*\.(py|md|yaml|toml)$', "-o", "CONTEXT.md"] - always_run: true + args: ["-c", '.*\.(py|md|yaml|toml)$', "-o", "CONTEXT.md"] diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 0e94a9c..38a496f 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -5,5 +5,4 @@ language: python files: '' pass_filenames: false - args: [".", "-o", "CONTEXT.md"] - always_run: true + args: ["-o", "CONTEXT.md"] diff --git a/README.md b/README.md index e17986a..a9f7602 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ if __name__ == "__main__": main() ``` -Running `project-context .` in the project root would generate the following output to stdout: +Running `project-context` from the project root would generate the following output to stdout: ````markdown # hello-world-pkg @@ -102,13 +102,13 @@ By default, all files tracked by Git are included in the directory tree. Generate context for the current directory and write it to stdout: ```bash -project-context . +project-context ``` Save output to a file: ```bash -project-context . -o CONTEXT.md +project-context -o CONTEXT.md ``` ### Customizing the Output @@ -119,6 +119,7 @@ By default, all files that are tracked by Git are included in the directory tree | Flag | Description | Example | |------|-------------|---------| +| `--root, -r` | Root directory to use. Defaults to the current working directory. | `-r my-repo` | | `--exclude, -e` | Regex patterns to exclude paths from the tree | `-e 'test.*'` | | `--include, -i` | Only include paths matching these regex patterns | `-i '.*\.py$' -i '.*\.y[a]?ml$` | | `--always-include, -a` | Always include these paths regardless of exclusion rules | `-a 'README.*'` | @@ -157,41 +158,41 @@ For any file in the root directory, the inclusion/exclusion rules are applied in ### Advanced Usage Examples -**Write the context to a custom dot-file:** +**Write the context of a subdirectory to a custom dot-file:** ```bash -project-context . -o .context.md +project-context -r src/my_project -o src/my_project/.context.md ``` **Include Python files, YAML files, and markdown files in the content section:** ```bash -project-context . -c '.*\.(py|md|yaml)$' +project-context -c '.*\.(py|md|yaml)$' ``` **Exclude multiple patterns from the project context:** ```bash -project-context . -e '^\..*' -e '.*\.yaml$' +project-context -e '^\..*' -e '.*\.yaml$' ``` **Exclude all YAML files, except for your `.pre-commit-config.yaml`:** ```bash -project-context . -e '.*\.yaml$' -a '\.pre-commit-config\.yaml' +project-context -e '.*\.yaml$' -a '\.pre-commit-config\.yaml' ``` **Only include typescript files and README files:** ```bash -project-context . -i '.*\.ts$' -a 'README.*' +project-context -i '.*\.ts$' -a 'README.*' ``` **Generate the output using a custom template and write to file:** ```bash -project-context . -t custom_template.md.j2 -o CONTEXT.md +project-context -t custom_template.md.j2 -o CONTEXT.md ``` ### Pre-commit Hook Integration @@ -205,11 +206,12 @@ repos: hooks: - id: project-context name: Generate LLM context from project contents - args: ['.', '-o', 'CONTEXT.md'] # defaults, feel free to customize filters/output here + files: '' # change as needed if you only want to update when specific files change + args: ['-o', 'CONTEXT.md'] # default args, update as needed ``` **Important**: -2. Consider adding `CONTEXT.md` to your `.gitignore` file if you don't want to track the generated context file in your repository, since it effectively duplicates your project contents. +Consider adding `CONTEXT.md` to your `.gitignore` file if you don't want to track the generated context file in your repository, since it effectively duplicates your project contents. The pre-commit hook will automatically regenerate the context file whenever you make a commit, ensuring your project context is always up-to-date for sharing with LLMs. @@ -263,5 +265,5 @@ Here are some guidelines and constraints on how the project should be maintained Then you can generate the context using your custom template like this: ```bash -project-context . -t custom_template.md.j2 -o CONTEXT.md +project-context -t custom_template.md.j2 -o CONTEXT.md ``` diff --git a/pyproject.toml b/pyproject.toml index 31a5b48..8fa1406 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ bump = true dev = [ "pre-commit>=4.2.0", "pytest>=8.4.0", + "pytest-cov>=6.2.1", ] [tool.coverage.run] @@ -38,9 +39,16 @@ source = ["src"] omit = ["tests/*"] [tool.coverage.report] +fail_under = 80 +show_missing = true exclude_lines = [ "pragma: no cover", "def __repr__", "raise AssertionError", "raise NotImplementedError", ] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = "test_*.py" +addopts = "-v --cov=project_context" diff --git a/src/project_context/main.py b/src/project_context/main.py index 39643e6..2838c6b 100644 --- a/src/project_context/main.py +++ b/src/project_context/main.py @@ -8,7 +8,7 @@ def main( - root: str | Path, + root: str | Path | None = None, exclude: list[str] | None = None, include: list[str] | None = None, always_include: list[str] | None = None, @@ -31,6 +31,8 @@ def main( rendering the output. If not provided, a default template is used. """ + if root is None: + root = Path.cwd() root = Path(root).resolve() if template: template_path = Path(template) @@ -64,7 +66,12 @@ def main( @click.command("project-context") -@click.argument("root", type=click.Path(exists=True, file_okay=False, path_type=Path)) # type: ignore +@click.option( + "--root", + "-r", + type=click.Path(exists=True, file_okay=False, path_type=Path), # type: ignore + help="Root directory of the project. If not provided, the current directory is used.", +) @click.option( "--exclude", "-e", @@ -105,21 +112,21 @@ def main( ), ) def cli( - root: Path, - exclude: tuple[str], + root: Path | None = None, + exclude: tuple[str] | None = None, include: tuple[str] | None = None, always_include: tuple[str] | None = None, contents: tuple[str] | None = None, output: Path | None = None, template: Path | None = None, ): - """Prints a tree structure of the files in the given ROOT directory.""" + """project-context generates LLM-friendly markdown files from your project contents.""" main( root, - list(exclude) if exclude else None, - list(include) if include else None, - list(always_include) if always_include else None, - list(contents) if contents else None, + exclude=list(exclude) if exclude else None, + include=list(include) if include else None, + always_include=list(always_include) if always_include else None, + contents=list(contents) if contents else None, output=output, template=template, ) diff --git a/src/project_context/tree.py b/src/project_context/tree.py index ff0d9cf..8f0577e 100644 --- a/src/project_context/tree.py +++ b/src/project_context/tree.py @@ -2,6 +2,7 @@ from collections import Counter from pathlib import Path from typing import Callable, Generator +from unittest.mock import patch from .utils import is_file_tracked, is_path_gitignored @@ -173,7 +174,8 @@ def __repr__(self) -> str: ) def __str__(self) -> str: - return "\n".join(str(path) for path in self.tree) + tree_str = "\n".join(str(path) for path in self.tree) + return f"```bash\n{tree_str}\n```" def to_markdown( self, diff --git a/src/project_context/utils.py b/src/project_context/utils.py index 5b0d6b3..3227d8b 100644 --- a/src/project_context/utils.py +++ b/src/project_context/utils.py @@ -2,7 +2,7 @@ from pathlib import Path -def get_root_paths(path: Path) -> tuple[Path | None, Path | None]: +def get_root_paths(path: str | Path) -> tuple[Path | None, Path | None]: """Returns the root path of the Git repository and the relative path from the root. If the path is not in a Git repository, returns (None, None). @@ -15,6 +15,7 @@ def get_root_paths(path: Path) -> tuple[Path | None, Path | None]: If the path is not in a Git repository, returns (None, None). """ try: + path = Path(path) if not path.exists(): return None, None @@ -39,9 +40,7 @@ def get_root_paths(path: Path) -> tuple[Path | None, Path | None]: stderr=subprocess.DEVNULL, # Suppress error output text=True, ).strip() - return Path(repo_root).resolve(), path.resolve().relative_to( - Path(repo_root).resolve() - ) + return Path(repo_root), path.relative_to(Path(repo_root)) except (subprocess.CalledProcessError, ValueError): return None, None diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..73c1934 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,257 @@ +import subprocess +import sys +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest +from click.testing import CliRunner + +from project_context.main import cli + + +@pytest.fixture +def temp_project(): + """Create a temporary project for testing main function.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + (temp_path / "main.py").write_text("print('hello world')") + (temp_path / "README.md").write_text("# Test") + yield temp_path + + +class TestCLIFunction: + """Test the CLI functionality using Click's testing utilities.""" + + @pytest.fixture + def runner(self): + """Create a Click test runner.""" + return CliRunner() + + def test_cli_basic_usage(self, temp_project, runner): + """Test basic CLI usage with just the root argument.""" + result = runner.invoke(cli, ["-r", str(temp_project)]) + + assert result.exit_code == 0 + assert temp_project.name in result.output + assert "Project Structure" in result.output + assert "Project Contents" in result.output + + def test_cli_with_output_file(self, temp_project, runner): + """Test CLI with output file option.""" + output_file = temp_project / "cli_output.md" + result = runner.invoke( + cli, ["-r", str(temp_project), "--output", str(output_file)] + ) + + assert result.exit_code == 0 + assert output_file.exists() + content = output_file.read_text() + assert "Project Structure" in content + + def test_cli_with_exclude_option(self, temp_project, runner): + """Test CLI with exclude patterns.""" + result = runner.invoke(cli, ["-r", str(temp_project), "--exclude", ".*\\.md$"]) + + assert result.exit_code == 0 + assert "README.md" not in result.output + + def test_cli_with_multiple_exclude_patterns(self, temp_project, runner): + """Test CLI with multiple exclude patterns.""" + result = runner.invoke( + cli, + [ + "--root", + str(temp_project), + "--exclude", + ".*\\.md$", + "--exclude", + ".*\\.py$", + ], + ) + + assert result.exit_code == 0 + + def test_cli_with_include_option(self, temp_project, runner): + """Test CLI with include patterns.""" + result = runner.invoke(cli, ["-r", str(temp_project), "--include", ".*\\.py$"]) + + assert result.exit_code == 0 + + def test_cli_with_contents_option(self, temp_project, runner): + """Test CLI with contents patterns.""" + result = runner.invoke(cli, ["-r", str(temp_project), "--contents", ".*\\.py$"]) + + assert result.exit_code == 0 + assert "print('hello world')" in result.output + + def test_cli_with_always_include_option(self, temp_project, runner): + """Test CLI with always-include patterns.""" + result = runner.invoke( + cli, + [ + "--root", + str(temp_project), + "--exclude", + ".*\\.py$", + "--always-include", + "main\\.py$", + ], + ) + + assert result.exit_code == 0 + + def test_cli_with_template_option(self, temp_project, runner): + """Test CLI with custom template.""" + template_path = temp_project / "cli_template.j2" + template_path.write_text("CLI Custom: {{ root }}\n{{ tree }}\n{{ contents }}") + + result = runner.invoke( + cli, ["--root", str(temp_project), "--template", str(template_path)] + ) + + assert result.exit_code == 0 + assert f"CLI Custom: {temp_project.name}" in result.output + + def test_cli_with_short_options(self, temp_project, runner): + """Test CLI with short option flags.""" + result = runner.invoke( + cli, + [ + "-r", + str(temp_project), + "-e", + ".*\\.md$", + "-i", + ".*\\.py$", + "-c", + ".*\\.py$", + ], + ) + + assert result.exit_code == 0 + assert "print('hello world')" in result.output + + def test_cli_nonexistent_directory(self, runner): + """Test CLI with non-existent directory.""" + result = runner.invoke(cli, ["-r", "/nonexistent/path"]) + + assert result.exit_code != 0 + assert ( + "does not exist" in result.output.lower() + or "invalid value" in result.output.lower() + ) + + @patch("project_context.main.main") + def test_cli_calls_main_with_correct_arguments( + self, mock_main, temp_project, runner + ): + """Test that CLI properly converts and passes arguments to main function.""" + runner.invoke( + cli, + [ + "--root", + str(temp_project), + "--exclude", + ".*\\.md$", + "--include", + ".*\\.py$", + "--contents", + ".*\\.py$", + ], + ) + + mock_main.assert_called_once() + args, kwargs = mock_main.call_args + + assert args[0] == temp_project + assert kwargs["exclude"] == [".*\\.md$"] + assert kwargs["include"] == [".*\\.py$"] + assert kwargs["contents"] == [".*\\.py$"] + + +class TestMainBlock: + """Test the if __name__ == '__main__' block.""" + + def test_main_block_execution_via_subprocess(self, temp_project): + """Test that the module can be run directly as a script.""" + result = subprocess.run( + [sys.executable, "-m", "project_context.main", "-r", str(temp_project)], + capture_output=True, + text=True, + ) + + assert result.returncode == 0 + assert temp_project.name in result.stdout + assert "Project Structure" in result.stdout + + def test_main_block_with_cli_options(self, temp_project): + """Test the main block with various CLI options.""" + result = subprocess.run( + [ + sys.executable, + "-m", + "project_context.main", + "--root", + str(temp_project), + "--exclude", + ".*\\.md$", + "--contents", + ".*\\.py$", + ], + capture_output=True, + text=True, + ) + + assert result.returncode == 0 + assert "print('hello world')" in result.stdout + assert "README.md" not in result.stdout + + def test_main_block_error_handling(self): + """Test the main block with invalid arguments.""" + result = subprocess.run( + [sys.executable, "-m", "project_context.main", "-r", "/nonexistent/path"], + capture_output=True, + text=True, + ) + + assert result.returncode != 0 + assert ( + "does not exist" in result.stderr.lower() + or "invalid value" in result.stderr.lower() + ) + + @patch("project_context.main.cli") + def test_main_block_code_coverage(self, mock_cli): + """Test that the main block calls cli() when __name__ == '__main__'.""" + # This test ensures the if __name__ == '__main__' block is covered, + # we need to execute the code in a way that __name__ == '__main__' + + # Create a temporary script that imports and runs the main block logic + import tempfile + import textwrap + + script_content = textwrap.dedent(""" + if __name__ == '__main__': + from project_context.main import cli + cli() + """) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: + f.write(script_content) + script_path = f.name + + try: + # Run the script - this will execute cli() in the main block context + result = subprocess.run( + [sys.executable, script_path, "--help"], capture_output=True, text=True + ) + + # Should show help and exit successfully + assert result.returncode == 0 + assert "project-context" in result.stdout + assert "Usage" in result.stdout + finally: + import os + + os.unlink(script_path) diff --git a/tests/test_main.py b/tests/test_main.py index 464af06..d9dab24 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -36,6 +36,17 @@ def test_main_function_with_output_file(self, temp_project): assert "Project Structure" in content assert "Project Contents" in content + @patch("project_context.main.Path") + def test_main_function_without_root(self, mock_path, temp_project): + output_file = temp_project / "output.md" + mock_path.return_value = temp_project + main(output=output_file) + + assert output_file.exists() + content = output_file.read_text() + assert "Project Structure" in content + assert "Project Contents" in content + def test_main_function_with_filters(self, temp_project, capsys): main(temp_project, include=[".*\\.py$"], contents=[".*\\.py$"]) captured = capsys.readouterr() diff --git a/tests/test_tree.py b/tests/test_tree.py index d3a2966..aef9c47 100644 --- a/tests/test_tree.py +++ b/tests/test_tree.py @@ -4,7 +4,6 @@ import pytest -from project_context.main import main from project_context.tree import ( ProjectPath, ProjectTree, @@ -152,67 +151,96 @@ def test_project_tree_indexing(self, temp_project): first_path = tree[0] assert isinstance(first_path, ProjectPath) + def test_project_path_str_nested_structure(self): + """Test string representation with multiple levels of nesting.""" + # Create a 3-level nested structure: root -> parent -> child + root = ProjectPath("/project") + parent = ProjectPath("/project/parent", parent=root, is_last=False) + child = ProjectPath("/project/parent/child.py", parent=parent, is_last=True) -class TestMainFunction: - """Test the main function and CLI functionality.""" + with patch.object(Path, "is_dir", return_value=False): + result = str(child) + # Should contain the nested prefix structure + assert "└── child.py" in result + assert "│ " in result # parent_prefix_last from the parent level - @pytest.fixture - def temp_project(self): - """Create a temporary project for testing main function.""" - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - (temp_path / "main.py").write_text("print('hello world')") - (temp_path / "README.md").write_text("# Test") - yield temp_path + def test_project_path_str_multiple_children(self): + """Test string representation with multiple children at different levels.""" + root = ProjectPath("/project") + parent1 = ProjectPath("/project/dir1", parent=root, is_last=False) + child = ProjectPath("/project/dir1/file.py", parent=parent1, is_last=True) + + with patch.object(Path, "is_dir", return_value=False): + result = str(child) + # Should use parent_prefix (4 spaces) since parent1 is not last + assert "│ └── file.py" in result + + @patch("project_context.tree.is_file_tracked") + @patch("project_context.tree.is_path_gitignored") + def test_default_inclusion_check_git_tracked_file( + self, mock_gitignored, mock_tracked, temp_project + ): + """Test that git-tracked files are included when in a git repo.""" + # Mock git repo detection and file tracking + mock_tracked.side_effect = ( + lambda path: str(path).endswith("tracked.py") + if hasattr(path, "endswith") + else True + ) + mock_gitignored.return_value = False - def test_main_function_basic_usage(self, temp_project, capsys): - main(temp_project) - captured = capsys.readouterr() + inclusion_check = ProjectTree._default_inclusion_check(temp_project) - assert temp_project.name in captured.out - assert "Project Structure" in captured.out - assert "Project Contents" in captured.out + test_path = temp_project / "tracked.py" + test_path.touch() - def test_main_function_with_output_file(self, temp_project): - output_file = temp_project / "output.md" - main(temp_project, output=output_file) + # Should include git-tracked files + assert inclusion_check(test_path) is True - assert output_file.exists() - content = output_file.read_text() - assert "Project Structure" in content - assert "Project Contents" in content + @patch("project_context.tree.is_file_tracked") + @patch("project_context.tree.is_path_gitignored") + def test_default_inclusion_check_dotfile_exclusion( + self, mock_gitignored, mock_tracked, temp_project + ): + """Test that dotfiles are excluded when not git-tracked.""" + # Mock git repo but file is not tracked + mock_tracked.side_effect = ( + lambda path: False if str(path).endswith(".hidden") else True + ) + mock_gitignored.return_value = False - def test_main_function_with_filters(self, temp_project, capsys): - main(temp_project, include=[".*\\.py$"], contents=[".*\\.py$"]) - captured = capsys.readouterr() + inclusion_check = ProjectTree._default_inclusion_check(temp_project) - assert "print('hello world')" in captured.out - assert "# Test" not in captured.out + # Test dotfile exclusion + dotfile_path = temp_project / ".hidden" + dotfile_path.touch() - def test_main_function_with_custom_template(self, temp_project): - template_path = temp_project / "template.j2" - template_path.write_text("Custom: {{ root }}\n{{ tree }}\n{{ contents }}") + # Should exclude dotfiles that aren't git-tracked + assert inclusion_check(dotfile_path) is False - output_file = temp_project / "output.md" - main(temp_project, template=template_path, output=output_file) + @patch("project_context.tree.is_file_tracked") + @patch("project_context.tree.is_path_gitignored") + def test_default_inclusion_check_gitignored_exclusion( + self, mock_gitignored, mock_tracked, temp_project + ): + """Test that gitignored files are excluded.""" - content = output_file.read_text() - assert content.startswith(f"Custom: {temp_project.name}") + # Mock git repo detection - root is a git repo, but ignored.py is not tracked + def mock_tracked_side_effect(path): + if str(path) == str(temp_project): + return True # Root is git repo + elif str(path).endswith("ignored.py"): + return False # ignored.py is not tracked + else: + return True # Other files are tracked - def test_main_function_with_exclude_patterns(self, temp_project, capsys): - main(temp_project, exclude=[".*\\.md$"]) - captured = capsys.readouterr() + mock_tracked.side_effect = mock_tracked_side_effect + mock_gitignored.side_effect = lambda path: str(path).endswith("ignored.py") - # Should exclude markdown files from tree - assert "README.md" not in captured.out + inclusion_check = ProjectTree._default_inclusion_check(temp_project) - @patch("project_context.main.sys.stdout") - def test_main_function_stdout_writing(self, mock_stdout, temp_project): - main(temp_project, contents=[".*\\.py$"]) - mock_stdout.write.assert_called() + ignored_path = temp_project / "ignored.py" + ignored_path.touch() - # Verify the content written to stdout - written_content = "".join( - call.args[0] for call in mock_stdout.write.call_args_list - ) - assert "Project Structure" in written_content + # Should exclude gitignored files + assert inclusion_check(ignored_path) is False diff --git a/tests/test_utils.py b/tests/test_utils.py index 48517c7..feb5abf 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,159 +2,228 @@ from pathlib import Path from unittest.mock import MagicMock, patch +import pytest + from project_context.utils import ( + get_root_paths, is_file_tracked, is_path_gitignored, ) +@pytest.fixture +def git_repo(tmp_path): + """Create a git repository with tracked, untracked, and ignored files.""" + # Initialize git repo + subprocess.run(["git", "init"], cwd=tmp_path, check=True, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True + ) + subprocess.run( + ["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True + ) + + # Create and track a file + tracked_file = tmp_path / "tracked.py" + tracked_file.write_text("# tracked file") + subprocess.run(["git", "add", "tracked.py"], cwd=tmp_path, check=True) + subprocess.run( + ["git", "commit", "-m", "initial"], + cwd=tmp_path, + check=True, + capture_output=True, + ) + + # Create .gitignore first + gitignore = tmp_path / ".gitignore" + gitignore.write_text("*.log\nignored.py\n") + + # Create a subdirectory with tracked files + subdir = tmp_path / "subdir" + subdir.mkdir() + tracked_sub = subdir / "tracked_sub.py" + tracked_sub.write_text("# tracked in subdir") + + # Add gitignore and subdir files + subprocess.run(["git", "add", ".gitignore", "subdir/"], cwd=tmp_path, check=True) + subprocess.run( + ["git", "commit", "-m", "add more files"], + cwd=tmp_path, + check=True, + capture_output=True, + ) + + # Create untracked and ignored files AFTER commits + untracked_file = tmp_path / "untracked.py" + untracked_file.write_text("# untracked file") + + ignored_file = tmp_path / "ignored.py" + ignored_file.write_text("# ignored file") + + return tmp_path + + class TestGitUtilities: """Test Git-related utility functions.""" - @patch("project_context.utils.subprocess.run") + def test_get_root_paths_returns_none_specifically_for_git_directory(self, git_repo): + """Test that get_root_paths returns (None, None) specifically because path is .git directory.""" + # Test with the .git directory - should return (None, None) + git_dir = git_repo / ".git" + result_git = get_root_paths(git_dir) + assert result_git == (None, None) + + # Test with a regular directory in the same repo - should succeed + regular_dir = git_repo / "subdir" + result_regular = get_root_paths(regular_dir) + assert result_regular != (None, None) + assert result_regular[0] == git_repo + + def test_get_root_paths_with_nonexistent_path(self, tmp_path): + """Test get_root_paths with non-existent path.""" + nonexistent = tmp_path / "does_not_exist" + result = get_root_paths(nonexistent) + assert result == (None, None) + + def test_get_root_paths_outside_git_repo(self, tmp_path): + """Test get_root_paths outside a git repository.""" + regular_dir = tmp_path / "not_git" + regular_dir.mkdir() + result = get_root_paths(regular_dir) + assert result == (None, None) + + def test_is_file_tracked_with_tracked_file(self, git_repo): + """Test is_file_tracked returns True for tracked files.""" + tracked_file = git_repo / "tracked.py" + assert is_file_tracked(tracked_file) is True + + # Test with file in subdirectory + tracked_sub = git_repo / "subdir" / "tracked_sub.py" + assert is_file_tracked(tracked_sub) is True + + def test_is_file_tracked_with_untracked_file(self, git_repo): + """Test is_file_tracked returns False for untracked files.""" + untracked_file = git_repo / "untracked.py" + assert is_file_tracked(untracked_file) is False + + def test_is_file_tracked_outside_git_repo(self, tmp_path): + """Test is_file_tracked returns False outside git repository.""" + regular_file = tmp_path / "file.py" + regular_file.write_text("content") + assert is_file_tracked(regular_file) is False + + def test_is_path_gitignored_with_ignored_file(self, git_repo): + """Test is_path_gitignored returns True for ignored files.""" + ignored_file = git_repo / "ignored.py" + assert is_path_gitignored(ignored_file) is True + + # Test with pattern match + log_file = git_repo / "debug.log" + log_file.write_text("log content") + assert is_path_gitignored(log_file) is True + + def test_is_path_gitignored_with_tracked_file(self, git_repo): + """Test is_path_gitignored returns False for tracked files.""" + tracked_file = git_repo / "tracked.py" + assert is_path_gitignored(tracked_file) is False + + def test_is_path_gitignored_outside_git_repo(self, tmp_path): + """Test is_path_gitignored returns False outside git repository.""" + regular_file = tmp_path / "file.py" + regular_file.write_text("content") + assert is_path_gitignored(regular_file) is False + @patch("project_context.utils.subprocess.check_output") - def test_is_file_tracked_returns_true_for_tracked_file( - self, mock_check_output, mock_run + @patch("project_context.utils.subprocess.run") + @patch("project_context.utils.Path") + def test_get_root_paths_handles_subprocess_error( + self, mock_path_class, mock_run, mock_check_output ): - # Mock the git rev-parse --git-dir call + """Test get_root_paths returns (None, None) when subprocess.CalledProcessError is raised.""" + mock_path = MagicMock() + mock_path.exists.return_value = True + mock_path.name = "regular_dir" + mock_path.is_dir.return_value = True + mock_path_class.return_value = mock_path + + # First subprocess call succeeds, second fails mock_run.return_value = MagicMock(returncode=0) + mock_check_output.side_effect = subprocess.CalledProcessError(1, "git") - mock_check_output.side_effect = [ - "/repo/root\n", # git rev-parse --show-toplevel - "file.py\n", # git ls-files --error-unmatch - ] - - with patch("project_context.utils.Path") as mock_path_class: - # Create mock path objects - mock_file_path = MagicMock() - mock_file_path.is_file.return_value = True - mock_file_path.is_dir.return_value = False - - # Create mock parent directory - mock_parent_dir = MagicMock() - mock_parent_dir.exists.return_value = True - mock_file_path.parent = mock_parent_dir - - # Create mock resolved paths for relative_to calculation - mock_resolved_file = MagicMock() - mock_resolved_repo = MagicMock() - mock_resolved_file.relative_to.return_value = Path("file.py") - - # Configure Path class mock to return appropriate objects - def path_side_effect(path_str): - if path_str == "/repo/root/file.py": - return mock_file_path - elif path_str == "/repo/root": - mock_repo_path = MagicMock() - mock_repo_path.resolve.return_value = mock_resolved_repo - return mock_repo_path - else: - # For any other Path creation, return a basic mock - mock_other = MagicMock() - mock_other.resolve.return_value = mock_resolved_file - return mock_other - - mock_path_class.side_effect = path_side_effect - - result = is_file_tracked("/repo/root/file.py") - assert result is True + result = get_root_paths("/some/path") + assert result == (None, None) - @patch("project_context.utils.subprocess.run") @patch("project_context.utils.subprocess.check_output") - def test_is_file_tracked_returns_false_for_untracked_file( - self, mock_check_output, mock_run + @patch("project_context.utils.subprocess.run") + @patch("project_context.utils.Path") + def test_get_root_paths_handles_value_error( + self, mock_path_class, mock_run, mock_check_output ): - # Mock the git rev-parse --git-dir call - mock_run.return_value = MagicMock(returncode=0) + """Test get_root_paths returns (None, None) when ValueError is raised.""" + mock_path = MagicMock() + mock_path.exists.return_value = True + mock_path.name = "regular_dir" + mock_path.is_dir.return_value = True + mock_path_class.return_value = mock_path - mock_check_output.side_effect = [ - "/repo/root\n", # git rev-parse --show-toplevel - subprocess.CalledProcessError(1, "git ls-files"), # git ls-files fails - ] + mock_run.return_value = MagicMock(returncode=0) + mock_check_output.return_value = "/repo/root\n" - with patch("project_context.utils.Path") as mock_path: - # Mock Path behavior - mock_path_obj = MagicMock() - mock_path_obj.is_file.return_value = True - mock_path_obj.parent = Path("/repo/root") - mock_path.return_value = mock_path_obj + # Make relative_to raise ValueError + mock_path.relative_to.side_effect = ValueError("Not a relative path") - result = is_file_tracked("/repo/root/untracked.py") - assert result is False + result = get_root_paths("/some/path") + assert result == (None, None) - @patch("project_context.utils.subprocess.run") + @patch("project_context.utils.get_root_paths") @patch("project_context.utils.subprocess.check_output") - def test_is_file_tracked_returns_false_for_fake_file( - self, mock_check_output, mock_run + @patch("project_context.utils.Path") + def test_is_file_tracked_handles_value_error( + self, mock_path_class, mock_check_output, mock_get_root_paths ): - # Mock the git rev-parse --git-dir call - mock_run.return_value = MagicMock(returncode=0) + """Test is_file_tracked returns False when ValueError is raised.""" + mock_path = MagicMock() + mock_path_class.return_value = mock_path - mock_check_output.side_effect = [ - "/repo/root\n", # git rev-parse --show-toplevel - subprocess.CalledProcessError(1, "git ls-files"), # git ls-files fails - ] - result = is_file_tracked("/repo/root/untracked.py") - assert result is False + # get_root_paths succeeds + mock_get_root_paths.return_value = (Path("/repo"), Path("file.py")) - @patch("project_context.utils.subprocess.run") - def test_is_file_tracked_handles_git_error(self, mock_run): - # Mock the git rev-parse --git-dir call to fail (not in git repo) - mock_run.return_value = MagicMock(returncode=1) + # But subprocess.check_output raises ValueError + mock_check_output.side_effect = ValueError("Invalid path") - result = is_file_tracked("/not/a/git/repo/file.py") + result = is_file_tracked("/repo/file.py") assert result is False + @patch("project_context.utils.get_root_paths") @patch("project_context.utils.subprocess.run") - @patch("project_context.utils.subprocess.check_output") - def test_is_path_gitignored_returns_true_for_ignored_path( - self, mock_check_output, mock_run_main + @patch("project_context.utils.Path") + def test_is_path_gitignored_handles_subprocess_error( + self, mock_path_class, mock_run, mock_get_root_paths ): - # Mock the git rev-parse --git-dir call - mock_run_main.return_value = MagicMock(returncode=0) - - mock_check_output.return_value = "/repo/root\n" + """Test is_path_gitignored returns False when subprocess.CalledProcessError is raised.""" + mock_path = MagicMock() + mock_path_class.return_value = mock_path - # Mock the git check-ignore call - with patch("project_context.utils.subprocess.run") as mock_run_check: - with patch("project_context.utils.Path") as mock_path: - # Mock Path behavior - mock_path_obj = MagicMock() - mock_path_obj.exists.return_value = True - mock_path_obj.is_dir.return_value = False - mock_path.return_value = mock_path_obj - mock_run_check.return_value = MagicMock(returncode=0) - result = is_path_gitignored("/repo/root/.env") - assert result is True - - @patch("project_context.utils.subprocess.run") - @patch("project_context.utils.subprocess.check_output") - def test_is_path_gitignored_returns_false_for_tracked_path( - self, mock_check_output, mock_run_main - ): - # Mock the git rev-parse --git-dir call - mock_run_main.return_value = MagicMock(returncode=0) + # get_root_paths succeeds + mock_get_root_paths.return_value = (Path("/repo"), Path("file.py")) - mock_check_output.return_value = "/repo/root\n" + # But subprocess.run raises CalledProcessError + mock_run.side_effect = subprocess.CalledProcessError(1, "git") - # Mock the git check-ignore call to fail (file not ignored) - with patch("project_context.utils.subprocess.run") as mock_run_check: - mock_run_check.return_value = MagicMock(returncode=1) - result = is_path_gitignored("/repo/root/file.py") - assert result is False + result = is_path_gitignored("/repo/file.py") + assert result is False - @patch("project_context.utils.subprocess.run") - @patch("project_context.utils.subprocess.check_output") - def test_is_path_gitignored_returns_true_for_fake_path( - self, mock_check_output, mock_run_main + @patch("project_context.utils.get_root_paths") + @patch("project_context.utils.Path") + def test_is_path_gitignored_handles_value_error( + self, mock_path_class, mock_get_root_paths ): - # Mock the git rev-parse --git-dir call - mock_run_main.return_value = MagicMock(returncode=0) + """Test is_path_gitignored returns False when ValueError is raised.""" + mock_path = MagicMock() + mock_path_class.return_value = mock_path - mock_check_output.return_value = "/repo/root\n" + # get_root_paths raises ValueError + mock_get_root_paths.side_effect = ValueError("Invalid path operation") - # Mock the git check-ignore call - with patch("project_context.utils.subprocess.run") as mock_run_check: - mock_run_check.return_value = MagicMock(returncode=0) - result = is_path_gitignored("/repo/root/.env") - assert result is False + result = is_path_gitignored("/repo/file.py") + assert result is False diff --git a/uv.lock b/uv.lock index 62319cc..b3959b4 100644 --- a/uv.lock +++ b/uv.lock @@ -32,6 +32,65 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/e0/98670a80884f64578f0c22cd70c5e81a6e07b08167721c7487b4d70a7ca0/coverage-7.9.1.tar.gz", hash = "sha256:6cf43c78c4282708a28e466316935ec7489a9c487518a77fa68f716c67909cec", size = 813650, upload-time = "2025-06-13T13:02:28.627Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/34/fa69372a07d0903a78ac103422ad34db72281c9fc625eba94ac1185da66f/coverage-7.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:95c765060e65c692da2d2f51a9499c5e9f5cf5453aeaf1420e3fc847cc060582", size = 212146, upload-time = "2025-06-13T13:00:48.496Z" }, + { url = "https://files.pythonhosted.org/packages/27/f0/da1894915d2767f093f081c42afeba18e760f12fdd7a2f4acbe00564d767/coverage-7.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ba383dc6afd5ec5b7a0d0c23d38895db0e15bcba7fb0fa8901f245267ac30d86", size = 212536, upload-time = "2025-06-13T13:00:51.535Z" }, + { url = "https://files.pythonhosted.org/packages/10/d5/3fc33b06e41e390f88eef111226a24e4504d216ab8e5d1a7089aa5a3c87a/coverage-7.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37ae0383f13cbdcf1e5e7014489b0d71cc0106458878ccde52e8a12ced4298ed", size = 245092, upload-time = "2025-06-13T13:00:52.883Z" }, + { url = "https://files.pythonhosted.org/packages/0a/39/7aa901c14977aba637b78e95800edf77f29f5a380d29768c5b66f258305b/coverage-7.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69aa417a030bf11ec46149636314c24c8d60fadb12fc0ee8f10fda0d918c879d", size = 242806, upload-time = "2025-06-13T13:00:54.571Z" }, + { url = "https://files.pythonhosted.org/packages/43/fc/30e5cfeaf560b1fc1989227adedc11019ce4bb7cce59d65db34fe0c2d963/coverage-7.9.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a4be2a28656afe279b34d4f91c3e26eccf2f85500d4a4ff0b1f8b54bf807338", size = 244610, upload-time = "2025-06-13T13:00:56.932Z" }, + { url = "https://files.pythonhosted.org/packages/bf/15/cca62b13f39650bc87b2b92bb03bce7f0e79dd0bf2c7529e9fc7393e4d60/coverage-7.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:382e7ddd5289f140259b610e5f5c58f713d025cb2f66d0eb17e68d0a94278875", size = 244257, upload-time = "2025-06-13T13:00:58.545Z" }, + { url = "https://files.pythonhosted.org/packages/cd/1a/c0f2abe92c29e1464dbd0ff9d56cb6c88ae2b9e21becdb38bea31fcb2f6c/coverage-7.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e5532482344186c543c37bfad0ee6069e8ae4fc38d073b8bc836fc8f03c9e250", size = 242309, upload-time = "2025-06-13T13:00:59.836Z" }, + { url = "https://files.pythonhosted.org/packages/57/8d/c6fd70848bd9bf88fa90df2af5636589a8126d2170f3aade21ed53f2b67a/coverage-7.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a39d18b3f50cc121d0ce3838d32d58bd1d15dab89c910358ebefc3665712256c", size = 242898, upload-time = "2025-06-13T13:01:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9e/6ca46c7bff4675f09a66fe2797cd1ad6a24f14c9c7c3b3ebe0470a6e30b8/coverage-7.9.1-cp311-cp311-win32.whl", hash = "sha256:dd24bd8d77c98557880def750782df77ab2b6885a18483dc8588792247174b32", size = 214561, upload-time = "2025-06-13T13:01:04.012Z" }, + { url = "https://files.pythonhosted.org/packages/a1/30/166978c6302010742dabcdc425fa0f938fa5a800908e39aff37a7a876a13/coverage-7.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:6b55ad10a35a21b8015eabddc9ba31eb590f54adc9cd39bcf09ff5349fd52125", size = 215493, upload-time = "2025-06-13T13:01:05.702Z" }, + { url = "https://files.pythonhosted.org/packages/60/07/a6d2342cd80a5be9f0eeab115bc5ebb3917b4a64c2953534273cf9bc7ae6/coverage-7.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ad935f0016be24c0e97fc8c40c465f9c4b85cbbe6eac48934c0dc4d2568321e", size = 213869, upload-time = "2025-06-13T13:01:09.345Z" }, + { url = "https://files.pythonhosted.org/packages/68/d9/7f66eb0a8f2fce222de7bdc2046ec41cb31fe33fb55a330037833fb88afc/coverage-7.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8de12b4b87c20de895f10567639c0797b621b22897b0af3ce4b4e204a743626", size = 212336, upload-time = "2025-06-13T13:01:10.909Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/e07cb920ef3addf20f052ee3d54906e57407b6aeee3227a9c91eea38a665/coverage-7.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5add197315a054e92cee1b5f686a2bcba60c4c3e66ee3de77ace6c867bdee7cb", size = 212571, upload-time = "2025-06-13T13:01:12.518Z" }, + { url = "https://files.pythonhosted.org/packages/78/f8/96f155de7e9e248ca9c8ff1a40a521d944ba48bec65352da9be2463745bf/coverage-7.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600a1d4106fe66f41e5d0136dfbc68fe7200a5cbe85610ddf094f8f22e1b0300", size = 246377, upload-time = "2025-06-13T13:01:14.87Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cf/1d783bd05b7bca5c10ded5f946068909372e94615a4416afadfe3f63492d/coverage-7.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a876e4c3e5a2a1715a6608906aa5a2e0475b9c0f68343c2ada98110512ab1d8", size = 243394, upload-time = "2025-06-13T13:01:16.23Z" }, + { url = "https://files.pythonhosted.org/packages/02/dd/e7b20afd35b0a1abea09fb3998e1abc9f9bd953bee548f235aebd2b11401/coverage-7.9.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81f34346dd63010453922c8e628a52ea2d2ccd73cb2487f7700ac531b247c8a5", size = 245586, upload-time = "2025-06-13T13:01:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/4e/38/b30b0006fea9d617d1cb8e43b1bc9a96af11eff42b87eb8c716cf4d37469/coverage-7.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:888f8eee13f2377ce86d44f338968eedec3291876b0b8a7289247ba52cb984cd", size = 245396, upload-time = "2025-06-13T13:01:19.164Z" }, + { url = "https://files.pythonhosted.org/packages/31/e4/4d8ec1dc826e16791f3daf1b50943e8e7e1eb70e8efa7abb03936ff48418/coverage-7.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9969ef1e69b8c8e1e70d591f91bbc37fc9a3621e447525d1602801a24ceda898", size = 243577, upload-time = "2025-06-13T13:01:22.433Z" }, + { url = "https://files.pythonhosted.org/packages/25/f4/b0e96c5c38e6e40ef465c4bc7f138863e2909c00e54a331da335faf0d81a/coverage-7.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:60c458224331ee3f1a5b472773e4a085cc27a86a0b48205409d364272d67140d", size = 244809, upload-time = "2025-06-13T13:01:24.143Z" }, + { url = "https://files.pythonhosted.org/packages/8a/65/27e0a1fa5e2e5079bdca4521be2f5dabf516f94e29a0defed35ac2382eb2/coverage-7.9.1-cp312-cp312-win32.whl", hash = "sha256:5f646a99a8c2b3ff4c6a6e081f78fad0dde275cd59f8f49dc4eab2e394332e74", size = 214724, upload-time = "2025-06-13T13:01:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a8/d5b128633fd1a5e0401a4160d02fa15986209a9e47717174f99dc2f7166d/coverage-7.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:30f445f85c353090b83e552dcbbdad3ec84c7967e108c3ae54556ca69955563e", size = 215535, upload-time = "2025-06-13T13:01:27.861Z" }, + { url = "https://files.pythonhosted.org/packages/a3/37/84bba9d2afabc3611f3e4325ee2c6a47cd449b580d4a606b240ce5a6f9bf/coverage-7.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:af41da5dca398d3474129c58cb2b106a5d93bbb196be0d307ac82311ca234342", size = 213904, upload-time = "2025-06-13T13:01:29.202Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a7/a027970c991ca90f24e968999f7d509332daf6b8c3533d68633930aaebac/coverage-7.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:31324f18d5969feef7344a932c32428a2d1a3e50b15a6404e97cba1cc9b2c631", size = 212358, upload-time = "2025-06-13T13:01:30.909Z" }, + { url = "https://files.pythonhosted.org/packages/f2/48/6aaed3651ae83b231556750280682528fea8ac7f1232834573472d83e459/coverage-7.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c804506d624e8a20fb3108764c52e0eef664e29d21692afa375e0dd98dc384f", size = 212620, upload-time = "2025-06-13T13:01:32.256Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2a/f4b613f3b44d8b9f144847c89151992b2b6b79cbc506dee89ad0c35f209d/coverage-7.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef64c27bc40189f36fcc50c3fb8f16ccda73b6a0b80d9bd6e6ce4cffcd810bbd", size = 245788, upload-time = "2025-06-13T13:01:33.948Z" }, + { url = "https://files.pythonhosted.org/packages/04/d2/de4fdc03af5e4e035ef420ed26a703c6ad3d7a07aff2e959eb84e3b19ca8/coverage-7.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4fe2348cc6ec372e25adec0219ee2334a68d2f5222e0cba9c0d613394e12d86", size = 243001, upload-time = "2025-06-13T13:01:35.285Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e8/eed18aa5583b0423ab7f04e34659e51101135c41cd1dcb33ac1d7013a6d6/coverage-7.9.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34ed2186fe52fcc24d4561041979a0dec69adae7bce2ae8d1c49eace13e55c43", size = 244985, upload-time = "2025-06-13T13:01:36.712Z" }, + { url = "https://files.pythonhosted.org/packages/17/f8/ae9e5cce8885728c934eaa58ebfa8281d488ef2afa81c3dbc8ee9e6d80db/coverage-7.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25308bd3d00d5eedd5ae7d4357161f4df743e3c0240fa773ee1b0f75e6c7c0f1", size = 245152, upload-time = "2025-06-13T13:01:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c8/272c01ae792bb3af9b30fac14d71d63371db227980682836ec388e2c57c0/coverage-7.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:73e9439310f65d55a5a1e0564b48e34f5369bee943d72c88378f2d576f5a5751", size = 243123, upload-time = "2025-06-13T13:01:40.727Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d0/2819a1e3086143c094ab446e3bdf07138527a7b88cb235c488e78150ba7a/coverage-7.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:37ab6be0859141b53aa89412a82454b482c81cf750de4f29223d52268a86de67", size = 244506, upload-time = "2025-06-13T13:01:42.184Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4e/9f6117b89152df7b6112f65c7a4ed1f2f5ec8e60c4be8f351d91e7acc848/coverage-7.9.1-cp313-cp313-win32.whl", hash = "sha256:64bdd969456e2d02a8b08aa047a92d269c7ac1f47e0c977675d550c9a0863643", size = 214766, upload-time = "2025-06-13T13:01:44.482Z" }, + { url = "https://files.pythonhosted.org/packages/27/0f/4b59f7c93b52c2c4ce7387c5a4e135e49891bb3b7408dcc98fe44033bbe0/coverage-7.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:be9e3f68ca9edb897c2184ad0eee815c635565dbe7a0e7e814dc1f7cbab92c0a", size = 215568, upload-time = "2025-06-13T13:01:45.772Z" }, + { url = "https://files.pythonhosted.org/packages/09/1e/9679826336f8c67b9c39a359352882b24a8a7aee48d4c9cad08d38d7510f/coverage-7.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:1c503289ffef1d5105d91bbb4d62cbe4b14bec4d13ca225f9c73cde9bb46207d", size = 213939, upload-time = "2025-06-13T13:01:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/bb/5b/5c6b4e7a407359a2e3b27bf9c8a7b658127975def62077d441b93a30dbe8/coverage-7.9.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0b3496922cb5f4215bf5caaef4cf12364a26b0be82e9ed6d050f3352cf2d7ef0", size = 213079, upload-time = "2025-06-13T13:01:48.554Z" }, + { url = "https://files.pythonhosted.org/packages/a2/22/1e2e07279fd2fd97ae26c01cc2186e2258850e9ec125ae87184225662e89/coverage-7.9.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9565c3ab1c93310569ec0d86b017f128f027cab0b622b7af288696d7ed43a16d", size = 213299, upload-time = "2025-06-13T13:01:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/4c5125a4b69d66b8c85986d3321520f628756cf524af810baab0790c7647/coverage-7.9.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2241ad5dbf79ae1d9c08fe52b36d03ca122fb9ac6bca0f34439e99f8327ac89f", size = 256535, upload-time = "2025-06-13T13:01:51.314Z" }, + { url = "https://files.pythonhosted.org/packages/81/8b/e36a04889dda9960be4263e95e777e7b46f1bb4fc32202612c130a20c4da/coverage-7.9.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bb5838701ca68b10ebc0937dbd0eb81974bac54447c55cd58dea5bca8451029", size = 252756, upload-time = "2025-06-13T13:01:54.403Z" }, + { url = "https://files.pythonhosted.org/packages/98/82/be04eff8083a09a4622ecd0e1f31a2c563dbea3ed848069e7b0445043a70/coverage-7.9.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b30a25f814591a8c0c5372c11ac8967f669b97444c47fd794926e175c4047ece", size = 254912, upload-time = "2025-06-13T13:01:56.769Z" }, + { url = "https://files.pythonhosted.org/packages/0f/25/c26610a2c7f018508a5ab958e5b3202d900422cf7cdca7670b6b8ca4e8df/coverage-7.9.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2d04b16a6062516df97969f1ae7efd0de9c31eb6ebdceaa0d213b21c0ca1a683", size = 256144, upload-time = "2025-06-13T13:01:58.19Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8b/fb9425c4684066c79e863f1e6e7ecebb49e3a64d9f7f7860ef1688c56f4a/coverage-7.9.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7931b9e249edefb07cd6ae10c702788546341d5fe44db5b6108a25da4dca513f", size = 254257, upload-time = "2025-06-13T13:01:59.645Z" }, + { url = "https://files.pythonhosted.org/packages/93/df/27b882f54157fc1131e0e215b0da3b8d608d9b8ef79a045280118a8f98fe/coverage-7.9.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52e92b01041151bf607ee858e5a56c62d4b70f4dac85b8c8cb7fb8a351ab2c10", size = 255094, upload-time = "2025-06-13T13:02:01.37Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/cad1c3dbed8b3ee9e16fa832afe365b4e3eeab1fb6edb65ebbf745eabc92/coverage-7.9.1-cp313-cp313t-win32.whl", hash = "sha256:684e2110ed84fd1ca5f40e89aa44adf1729dc85444004111aa01866507adf363", size = 215437, upload-time = "2025-06-13T13:02:02.905Z" }, + { url = "https://files.pythonhosted.org/packages/99/4d/fad293bf081c0e43331ca745ff63673badc20afea2104b431cdd8c278b4c/coverage-7.9.1-cp313-cp313t-win_amd64.whl", hash = "sha256:437c576979e4db840539674e68c84b3cda82bc824dd138d56bead1435f1cb5d7", size = 216605, upload-time = "2025-06-13T13:02:05.638Z" }, + { url = "https://files.pythonhosted.org/packages/1f/56/4ee027d5965fc7fc126d7ec1187529cc30cc7d740846e1ecb5e92d31b224/coverage-7.9.1-cp313-cp313t-win_arm64.whl", hash = "sha256:18a0912944d70aaf5f399e350445738a1a20b50fbea788f640751c2ed9208b6c", size = 214392, upload-time = "2025-06-13T13:02:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e5/c723545c3fd3204ebde3b4cc4b927dce709d3b6dc577754bb57f63ca4a4a/coverage-7.9.1-pp39.pp310.pp311-none-any.whl", hash = "sha256:db0f04118d1db74db6c9e1cb1898532c7dcc220f1d2718f058601f7c3f499514", size = 204009, upload-time = "2025-06-13T13:02:25.787Z" }, + { url = "https://files.pythonhosted.org/packages/08/b8/7ddd1e8ba9701dea08ce22029917140e6f66a859427406579fd8d0ca7274/coverage-7.9.1-py3-none-any.whl", hash = "sha256:66b974b145aa189516b6bf2d8423e888b742517d37872f6ee4c5be0073bd9a3c", size = 204000, upload-time = "2025-06-13T13:02:27.173Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "distlib" version = "0.3.9" @@ -192,6 +251,7 @@ dependencies = [ dev = [ { name = "pre-commit" }, { name = "pytest" }, + { name = "pytest-cov" }, ] [package.metadata] @@ -204,6 +264,7 @@ requires-dist = [ dev = [ { name = "pre-commit", specifier = ">=4.2.0" }, { name = "pytest", specifier = ">=8.4.0" }, + { name = "pytest-cov", specifier = ">=6.2.1" }, ] [[package]] @@ -231,6 +292,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/de/afa024cbe022b1b318a3d224125aa24939e99b4ff6f22e0ba639a2eaee47/pytest-8.4.0-py3-none-any.whl", hash = "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e", size = 363797, upload-time = "2025-06-02T17:36:27.859Z" }, ] +[[package]] +name = "pytest-cov" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/99/668cade231f434aaa59bbfbf49469068d2ddd945000621d3d165d2e7dd7b/pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2", size = 69432, upload-time = "2025-06-12T10:47:47.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" }, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -266,6 +341,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, ] +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, +] + [[package]] name = "virtualenv" version = "20.31.2"