diff --git a/.flake8 b/.flake8 index 20f6276a..60998934 100644 --- a/.flake8 +++ b/.flake8 @@ -1,2 +1,2 @@ -[flake8] -max-line-length = 130 +[flake8] +max-line-length = 130 diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 02d034e4..d4bdc611 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -1,208 +1,220 @@ -# .github/workflows/cd.yml -name: TreeMapper CD - -permissions: - contents: write - -on: - workflow_dispatch: - inputs: - version: - description: 'Version to release (e.g., 1.0.0)' - required: true - publish_to_pypi: - description: 'Publish to PyPI' - required: true - default: 'false' - type: choice - options: - - 'true' - - 'false' - -jobs: - prepare-release: - name: Prepare Commit and Create GitHub Release/Tag - runs-on: ubuntu-latest - outputs: - version: ${{ steps.set_outputs.outputs.version }} - tag_name: ${{ steps.set_outputs.outputs.tag_name }} - commit_sha: ${{ steps.commit_push.outputs.commit_sha }} - upload_url: ${{ steps.create_release.outputs.upload_url }} - release_id: ${{ steps.create_release.outputs.id }} - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Set version in version.py - run: | - VERSION="${{ github.event.inputs.version }}" - echo "Setting version to $VERSION" - sed -i -E "s/__version__ = \".*\"/__version__ = \"$VERSION\"/" src/treemapper/version.py - echo "version.py content after change:" - cat src/treemapper/version.py - - - name: Commit and Push version bump - id: commit_push - run: | - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add src/treemapper/version.py - if ! git diff --staged --quiet; then - git commit -m "Release version ${{ github.event.inputs.version }}" - else - echo "No changes to commit." - fi - COMMIT_SHA=$(git rev-parse HEAD) - echo "Commit SHA: $COMMIT_SHA" - echo "commit_sha=$COMMIT_SHA" >> $GITHUB_OUTPUT - # Отправляем коммит в текущую ветку (важно для следующего шага) - # Тег будет создан действием create-release - git push origin HEAD - - - name: Create GitHub Release and Tag - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: v${{ github.event.inputs.version }} - release_name: Release ${{ github.event.inputs.version }} - commitish: ${{ steps.commit_push.outputs.commit_sha }} # Указываем SHA коммита - draft: false - prerelease: false - - - name: Set outputs for subsequent jobs - id: set_outputs - run: | - echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT - echo "tag_name=v${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT - - build-and-upload: - name: Build and Upload Assets - needs: prepare-release - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - asset_name: linux - python-version: '3.11' - - os: macos-latest - asset_name: macos - python-version: '3.11' - - os: windows-latest - asset_name: windows - python-version: '3.11' - runs-on: ${{ matrix.os }} - steps: - - name: Checkout Code at release tag - uses: actions/checkout@v4 - with: - ref: ${{ needs.prepare-release.outputs.tag_name }} - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Cache pip Dependencies - uses: actions/cache@v4 - id: cache-pip - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('**/setup.cfg') }} - restore-keys: | - ${{ runner.os }}-pip-${{ matrix.python-version }}- - ${{ runner.os }}-pip- - - - name: Install Dependencies (including PyInstaller) - run: | - python -m pip install --upgrade pip - pip install .[dev] - - - name: Build with PyInstaller - run: | - python -m PyInstaller --clean -y --dist ./dist/${{ matrix.asset_name }} treemapper.spec - - - name: Determine architecture - id: arch - shell: bash - run: | - ARCH=$(uname -m) - if [[ "${{ runner.os }}" == "Windows" ]]; then - if [[ "${{ runner.arch }}" == "X64" ]]; then ARCH="x86_64"; \ - elif [[ "${{ runner.arch }}" == "ARM64" ]]; then ARCH="arm64"; \ - else ARCH="unknown"; fi - elif [[ "${{ runner.os }}" == "macOS" ]] && [[ "$ARCH" == "arm64" ]]; then - echo "Detected ARM on macOS" - fi - echo "Determined ARCH: $ARCH" - echo "arch=$ARCH" >> $GITHUB_OUTPUT - - - - name: Upload Release Asset - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.prepare-release.outputs.upload_url }} - # ---> ИЗМЕНЕНИЕ: Убран лишний /treemapper из пути <--- - asset_path: ./dist/${{ matrix.asset_name }}/${{ matrix.os == 'windows-latest' && 'treemapper.exe' || 'treemapper' }} - asset_name: treemapper-${{ matrix.asset_name }}-${{ steps.arch.outputs.arch }}-v${{ needs.prepare-release.outputs.version }}${{ matrix.os == 'windows-latest' && '.exe' || '' }} - asset_content_type: application/octet-stream - - publish-to-pypi: - name: Publish to PyPI - needs: [prepare-release, build-and-upload] - if: github.event.inputs.publish_to_pypi == 'true' - runs-on: ubuntu-latest - environment: - name: pypi - url: https://pypi.org/p/treemapper - permissions: - id-token: write - steps: - - name: Checkout Code at release tag - uses: actions/checkout@v4 - with: - ref: ${{ needs.prepare-release.outputs.tag_name }} - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install build tools - run: | - python -m pip install --upgrade pip - pip install build - - - name: Build sdist and wheel - run: python -m build - - - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - - update-main-branch: - name: Update main branch (Merge Tag) - needs: [prepare-release, build-and-upload] - runs-on: ubuntu-latest - steps: - - name: Checkout main branch - uses: actions/checkout@v4 - with: - ref: main - fetch-depth: 0 - - - name: Merge tag into main - run: | - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - echo "Attempting to merge tag ${{ needs.prepare-release.outputs.tag_name }} into main" - git merge ${{ needs.prepare-release.outputs.tag_name }} --no-ff -m "Merge tag ${{ needs.prepare-release.outputs.tag_name }} into main" +# .github/workflows/cd.yml +name: TreeMapper CD + +permissions: + contents: write + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., 1.0.0)' + required: true + publish_to_pypi: + description: 'Publish to PyPI' + required: true + default: 'false' + type: choice + options: + - 'true' + - 'false' + +jobs: + prepare-release: + name: Prepare Commit and Create GitHub Release/Tag + runs-on: ubuntu-latest + outputs: + version: ${{ steps.set_outputs.outputs.version }} + tag_name: ${{ steps.set_outputs.outputs.tag_name }} + commit_sha: ${{ steps.commit_push.outputs.commit_sha }} + upload_url: ${{ steps.create_release.outputs.upload_url }} + release_id: ${{ steps.create_release.outputs.id }} + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Set version in version.py + run: | + VERSION="${{ github.event.inputs.version }}" + echo "Setting version to $VERSION" + # More robust version updating using Python to ensure consistent formatting + python -c " + with open('src/treemapper/version.py', 'r') as f: + content = f.read() + with open('src/treemapper/version.py', 'w') as f: + f.write(content.replace('__version__ = \"' + content.split('\"')[1] + '\"', '__version__ = \"$VERSION\"')) + " + echo "version.py content after change:" + cat src/treemapper/version.py + + - name: Commit and Push version bump + id: commit_push + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + + # Get current branch name for explicit push target + CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + echo "Current branch: $CURRENT_BRANCH" + + git add src/treemapper/version.py + if ! git diff --staged --quiet; then + git commit -m "Release version ${{ github.event.inputs.version }}" + else + echo "No changes to commit." + fi + COMMIT_SHA=$(git rev-parse HEAD) + echo "Commit SHA: $COMMIT_SHA" + echo "commit_sha=$COMMIT_SHA" >> $GITHUB_OUTPUT + + # Explicitly push to the current branch by name instead of using HEAD + # This prevents ambiguity about which branch is being updated + git push origin $CURRENT_BRANCH + + - name: Create GitHub Release and Tag + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: v${{ github.event.inputs.version }} + release_name: Release ${{ github.event.inputs.version }} + commitish: ${{ steps.commit_push.outputs.commit_sha }} # Указываем SHA коммита + draft: false + prerelease: false + + - name: Set outputs for subsequent jobs + id: set_outputs + run: | + echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT + echo "tag_name=v${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT + + build-and-upload: + name: Build and Upload Assets + needs: prepare-release + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + asset_name: linux + python-version: '3.11' + - os: macos-latest + asset_name: macos + python-version: '3.11' + - os: windows-latest + asset_name: windows + python-version: '3.11' + runs-on: ${{ matrix.os }} + steps: + - name: Checkout Code at release tag + uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare-release.outputs.tag_name }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Cache pip Dependencies + uses: actions/cache@v4 + id: cache-pip + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('**/setup.cfg') }} + restore-keys: | + ${{ runner.os }}-pip-${{ matrix.python-version }}- + ${{ runner.os }}-pip- + + - name: Install Dependencies (including PyInstaller) + run: | + python -m pip install --upgrade pip + pip install .[dev] + + - name: Build with PyInstaller + run: | + python -m PyInstaller --clean -y --dist ./dist/${{ matrix.asset_name }} treemapper.spec + + - name: Determine architecture + id: arch + shell: bash + run: | + ARCH=$(uname -m) + if [[ "${{ runner.os }}" == "Windows" ]]; then + if [[ "${{ runner.arch }}" == "X64" ]]; then ARCH="x86_64"; \ + elif [[ "${{ runner.arch }}" == "ARM64" ]]; then ARCH="arm64"; \ + else ARCH="unknown"; fi + elif [[ "${{ runner.os }}" == "macOS" ]] && [[ "$ARCH" == "arm64" ]]; then + echo "Detected ARM on macOS" + fi + echo "Determined ARCH: $ARCH" + echo "arch=$ARCH" >> $GITHUB_OUTPUT + + + - name: Upload Release Asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ needs.prepare-release.outputs.upload_url }} + # ---> ИЗМЕНЕНИЕ: Убран лишний /treemapper из пути <--- + asset_path: ./dist/${{ matrix.asset_name }}/${{ matrix.os == 'windows-latest' && 'treemapper.exe' || 'treemapper' }} + asset_name: treemapper-${{ matrix.asset_name }}-${{ steps.arch.outputs.arch }}-v${{ needs.prepare-release.outputs.version }}${{ matrix.os == 'windows-latest' && '.exe' || '' }} + asset_content_type: application/octet-stream + + publish-to-pypi: + name: Publish to PyPI + needs: [prepare-release, build-and-upload] + if: github.event.inputs.publish_to_pypi == 'true' + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/treemapper + permissions: + id-token: write + steps: + - name: Checkout Code at release tag + uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare-release.outputs.tag_name }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install build tools + run: | + python -m pip install --upgrade pip + pip install build + + - name: Build sdist and wheel + run: python -m build + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + update-main-branch: + name: Update main branch (Merge Tag) + needs: [prepare-release, build-and-upload] + runs-on: ubuntu-latest + steps: + - name: Checkout main branch + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - name: Merge tag into main + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + echo "Attempting to merge tag ${{ needs.prepare-release.outputs.tag_name }} into main" + git merge ${{ needs.prepare-release.outputs.tag_name }} --no-ff -m "Merge tag ${{ needs.prepare-release.outputs.tag_name }} into main" git push origin main \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6e0debf8..ecb9752d 100644 --- a/.gitignore +++ b/.gitignore @@ -162,4 +162,5 @@ cython_debug/ directory_tree.yaml -.pypirc \ No newline at end of file +.pypirc +**/.claude/settings.local.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..29c8fd9b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +## Bugfixes (2025-05-10) + +### Core Functionality +- **Fixed anchored gitignore patterns on Windows**: Root-anchored patterns in .gitignore files (e.g., `/file.txt`) now correctly ignore files at the root directory on all platforms, including Windows. +- **Fixed YAML writer problems with special filenames**: Filenames that would be interpreted as special YAML values (like `true`, `false`, numbers, or names with special characters) are now properly escaped and quoted in the output YAML. +- **Fixed gitignore negation logic**: Implemented proper hierarchical application of gitignore rules that better matches Git's behavior with respect to parent/child directory pattern interactions. +- **Improved default ignore patterns**: Added common Python-specific patterns (`.pyc`, `__pycache__`, etc.) to default ignores and fixed directory traversal to avoid scanning ignored directories. + +### Environment and Configuration +- **Fixed incorrect Black version in setup.cfg**: Changed the Black version from `black>=25.1.0` (which doesn't exist) to `black>=23.0.0`, ensuring that `pip install .[dev]` works correctly. + +### CI/CD Improvements +- **Improved version bumping in CD workflow**: Replaced brittle sed command with more robust Python script to ensure reliable version updates regardless of formatting changes. +- **Fixed ambiguous git push in CD workflow**: Changed the git push command to explicitly use the current branch name instead of HEAD, preventing version bumps from happening on arbitrary branches. + +### Reliability Improvements +- **Enhanced file permission handling**: Improved handling of permission-related errors for unreadable files and directories. +- **Added WSL compatibility**: Added special handling for permission-related tests in Windows Subsystem for Linux (WSL) environments. +- **Reduced verbosity**: Changed default verbosity level from INFO to ERROR to minimize console output unless errors occur. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..a67c218d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,114 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +TreeMapper is a Python tool that converts directory structures to YAML format, designed specifically for use with Large Language Models (LLMs). It maps entire codebases into structured YAML files, making it easy to analyze code, document projects, and work with AI tools. + +## Development Environment + +- Python 3.9+ required +- Package dependencies: pathspec, pyyaml + +## Building and Installation + +```bash +# Install in development mode +pip install -e . + +# Install with development dependencies +pip install -e ".[dev]" + +# Build distribution package +python -m build + +# Install from PyPI +pip install treemapper +``` + +## Testing + +```bash +# Run all tests +pytest + +# Run specific test file +pytest tests/test_basic.py + +# Run specific test +pytest tests/test_basic.py::test_basic_mapping + +# Run tests with coverage +pytest --cov=src/treemapper + +# Run tests in verbose mode +pytest -v +``` + +## Linting and Formatting + +```bash +# Run flake8 linter +flake8 src/treemapper + +# Run black formatter +black src/treemapper + +# Run type checking with mypy +mypy src/treemapper + +# Run autoflake to remove unused imports +autoflake --remove-all-unused-imports -i src/treemapper/*.py +``` + +## Project Architecture + +The codebase is organized as follows: + +- `src/treemapper/`: Main package + - `treemapper.py`: Entry point and main orchestration + - `cli.py`: Command-line argument parsing + - `ignore.py`: Logic for handling ignore patterns (gitignore, treemapperignore) + - `tree.py`: Core tree building functionality + - `writer.py`: YAML output formatting and file writing + - `logger.py`: Logging configuration + +The application flow is: +1. Parse command-line arguments (`cli.py`) +2. Set up logging based on verbosity level (`logger.py`) +3. Load ignore patterns from various sources (`ignore.py`) +4. Build the directory tree structure (`tree.py`) +5. Write the tree structure to a YAML file (`writer.py`) + +## Running the Application + +```bash +# Map current directory +treemapper . + +# Map specific directory +treemapper /path/to/dir + +# Custom output file +treemapper . -o my-tree.yaml + +# Custom ignore patterns +treemapper . -i ignore.txt + +# Disable all default ignores +treemapper . --no-default-ignores + +# Set verbosity level (0=ERROR, 1=WARNING, 2=INFO, 3=DEBUG) +treemapper . -v 3 +``` + +## Creating a Distribution Package + +```bash +# Build package +python -m build + +# Create executable with PyInstaller +pyinstaller treemapper.spec +``` \ No newline at end of file diff --git a/README.md b/README.md index 865da168..094eeae7 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Options: -o, --output-file FILE Output YAML file (default: directory_tree.yaml) -i, --ignore-file FILE Custom ignore patterns file --no-default-ignores Disable all default ignores - -v, --verbosity [0-3] Logging verbosity (default: 2) + -v, --verbosity [0-3] Logging verbosity (default: 0) 0=ERROR, 1=WARNING, 2=INFO, 3=DEBUG -h, --help Show this help ``` diff --git a/setup.cfg b/setup.cfg index 07d55d39..57615ccb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -36,7 +36,7 @@ dev = twine>=4.0 pyinstaller>=5.0 flake8>=5.0 - black>=25.1.0 + black>=23.0.0 mypy>=1.0 types-PyYAML pyyaml diff --git a/src/treemapper/cli.py b/src/treemapper/cli.py index 522f9178..a0d5420e 100644 --- a/src/treemapper/cli.py +++ b/src/treemapper/cli.py @@ -1,61 +1,61 @@ -# src/treemapper/cli.py -import argparse -import sys -from pathlib import Path -from typing import Optional, Tuple - - -# ---> ИЗМЕНЕНИЕ: Возвращаем Path для output_file, т.к. он всегда Path <--- -def parse_args() -> Tuple[Path, Optional[Path], Path, bool, int]: - """Parse command line arguments.""" - parser = argparse.ArgumentParser( - prog="treemapper", - description="Generate a YAML representation of a directory structure.", - formatter_class=argparse.ArgumentDefaultsHelpFormatter, - ) - - parser.add_argument("directory", nargs="?", default=".", help="The directory to analyze") - - parser.add_argument("-i", "--ignore-file", default=None, help="Path to the custom ignore file (optional)") - - parser.add_argument("-o", "--output-file", default="./directory_tree.yaml", help="Path to the output YAML file") - - parser.add_argument( - "--no-default-ignores", action="store_true", help="Disable default ignores (.treemapperignore, .gitignore, output file)" - ) - - parser.add_argument( - "-v", - "--verbosity", - type=int, - choices=range(0, 4), - default=2, - metavar="[0-3]", - help="Set verbosity level (0: ERROR, 1: WARNING, 2: INFO, 3: DEBUG)", - ) - - args = parser.parse_args() - - try: - root_dir = Path(args.directory).resolve(strict=True) - if not root_dir.is_dir(): - print(f"Error: The path '{root_dir}' is not a valid directory.", file=sys.stderr) - sys.exit(1) - except FileNotFoundError: - print(f"Error: The directory '{args.directory}' does not exist.", file=sys.stderr) - sys.exit(1) - except Exception as e: - print(f"Error resolving directory path '{args.directory}': {e}", file=sys.stderr) - sys.exit(1) - - output_file = Path(args.output_file) - if not output_file.is_absolute(): - output_file = Path.cwd() / output_file - - ignore_file_path: Optional[Path] = None - if args.ignore_file: - ignore_file_path = Path(args.ignore_file) - if not ignore_file_path.is_absolute(): - ignore_file_path = Path.cwd() / ignore_file_path - - return root_dir, ignore_file_path, output_file, args.no_default_ignores, args.verbosity +# src/treemapper/cli.py +import argparse +import sys +from pathlib import Path +from typing import Optional, Tuple + + +# ---> ИЗМЕНЕНИЕ: Возвращаем Path для output_file, т.к. он всегда Path <--- +def parse_args() -> Tuple[Path, Optional[Path], Path, bool, int]: + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + prog="treemapper", + description="Generate a YAML representation of a directory structure.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + + parser.add_argument("directory", nargs="?", default=".", help="The directory to analyze") + + parser.add_argument("-i", "--ignore-file", default=None, help="Path to the custom ignore file (optional)") + + parser.add_argument("-o", "--output-file", default="./directory_tree.yaml", help="Path to the output YAML file") + + parser.add_argument( + "--no-default-ignores", action="store_true", help="Disable default ignores (.treemapperignore, .gitignore, output file)" + ) + + parser.add_argument( + "-v", + "--verbosity", + type=int, + choices=range(0, 4), + default=0, + metavar="[0-3]", + help="Set verbosity level (0: ERROR, 1: WARNING, 2: INFO, 3: DEBUG)", + ) + + args = parser.parse_args() + + try: + root_dir = Path(args.directory).resolve(strict=True) + if not root_dir.is_dir(): + print(f"Error: The path '{root_dir}' is not a valid directory.", file=sys.stderr) + sys.exit(1) + except FileNotFoundError: + print(f"Error: The directory '{args.directory}' does not exist.", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"Error resolving directory path '{args.directory}': {e}", file=sys.stderr) + sys.exit(1) + + output_file = Path(args.output_file) + if not output_file.is_absolute(): + output_file = Path.cwd() / output_file + + ignore_file_path: Optional[Path] = None + if args.ignore_file: + ignore_file_path = Path(args.ignore_file) + if not ignore_file_path.is_absolute(): + ignore_file_path = Path.cwd() / ignore_file_path + + return root_dir, ignore_file_path, output_file, args.no_default_ignores, args.verbosity diff --git a/src/treemapper/ignore.py b/src/treemapper/ignore.py index 11837dd3..872a9d1c 100644 --- a/src/treemapper/ignore.py +++ b/src/treemapper/ignore.py @@ -1,144 +1,186 @@ -# src/treemapper/ignore.py -import logging -import os -from pathlib import Path - -# ---> ИЗМЕНЕНИЕ: Добавляем импорты из typing <--- -from typing import Dict, List, Optional, Tuple - -import pathspec - - -def read_ignore_file(file_path: Path) -> List[str]: - """Read the ignore patterns from the specified ignore file.""" - ignore_patterns = [] - if file_path.is_file(): - try: - with file_path.open("r", encoding="utf-8") as f: - ignore_patterns = [line.strip() for line in f if line.strip() and not line.startswith("#")] - logging.info(f"Using ignore patterns from {file_path}") - logging.debug(f"Read ignore patterns from {file_path}: {ignore_patterns}") - except IOError as e: - logging.warning(f"Could not read ignore file {file_path}: {e}") - except UnicodeDecodeError as e: - logging.warning(f"Could not decode ignore file {file_path} as UTF-8: {e}") - return ignore_patterns - - -def load_pathspec(patterns: List[str], syntax="gitwildmatch") -> pathspec.PathSpec: - """Load pathspec from a list of patterns.""" - spec = pathspec.PathSpec.from_lines(syntax, patterns) - logging.debug(f"Loaded pathspec with patterns: {patterns}") - return spec - - -# ---> ИЗМЕНЕНИЕ: Заменяем | None на Optional[...] <--- -def get_ignore_specs( - root_dir: Path, - custom_ignore_file: Optional[Path] = None, - no_default_ignores: bool = False, - output_file: Optional[Path] = None, -) -> Tuple[pathspec.PathSpec, Dict[Path, pathspec.PathSpec]]: - """Get combined ignore specs and git ignore specs.""" - default_patterns = get_default_patterns(root_dir, no_default_ignores, output_file) - custom_patterns = get_custom_patterns(root_dir, custom_ignore_file) - - if no_default_ignores: - combined_patterns = custom_patterns - if output_file: - try: - resolved_output = output_file.resolve() - resolved_root = root_dir.resolve() - if resolved_output.is_relative_to(resolved_root): - relative_output_str = resolved_output.relative_to(resolved_root).as_posix() - output_pattern = f"/{relative_output_str}" - if output_pattern not in combined_patterns: - combined_patterns.append(output_pattern) - logging.debug(f"Adding output file to ignores (no_default_ignores=True): {output_pattern}") - except ValueError: - pass - except Exception as e: - logging.warning(f"Could not determine relative path for output file {output_file}: {e}") - else: - combined_patterns = default_patterns + custom_patterns - - logging.debug(f"Ignore specs params: no_default_ignores={no_default_ignores}") - logging.debug(f"Default patterns (used unless no_default_ignores): {default_patterns}") - logging.debug(f"Custom patterns (-i): {custom_patterns}") - logging.debug(f"Combined patterns for spec: {combined_patterns}") - - combined_spec = load_pathspec(combined_patterns) - gitignore_specs = get_gitignore_specs(root_dir, no_default_ignores) - - return combined_spec, gitignore_specs - - -# ---> ИЗМЕНЕНИЕ: Заменяем | None на Optional[...] <--- -def get_default_patterns(root_dir: Path, no_default_ignores: bool, output_file: Optional[Path]) -> List[str]: - """Retrieve default ignore patterns ONLY IF no_default_ignores is FALSE.""" - if no_default_ignores: - return [] - - patterns = [] - treemapper_ignore_file = root_dir / ".treemapperignore" - patterns.extend(read_ignore_file(treemapper_ignore_file)) - - if output_file: - try: - resolved_output = output_file.resolve() - resolved_root = root_dir.resolve() - try: - relative_output = resolved_output.relative_to(resolved_root) - output_pattern = f"/{relative_output.as_posix()}" - patterns.append(output_pattern) - logging.debug(f"Adding output file to default ignores: {output_pattern}") - except ValueError: - logging.debug(f"Output file {output_file} is outside root directory {root_dir}, not adding to default ignores.") - except Exception as e: - logging.warning(f"Could not determine relative path for output file {output_file}: {e}") - - return patterns - - -# ---> ИЗМЕНЕНИЕ: Заменяем | None на Optional[...] <--- -def get_custom_patterns(root_dir: Path, custom_ignore_file: Optional[Path]) -> List[str]: - """Retrieve custom ignore patterns from the file specified with -i.""" - if not custom_ignore_file: - return [] - - if not custom_ignore_file.is_absolute(): - custom_ignore_file = Path.cwd() / custom_ignore_file - - if custom_ignore_file.is_file(): - return read_ignore_file(custom_ignore_file) - else: - logging.warning(f"Custom ignore file '{custom_ignore_file}' not found.") - return [] - - -def get_gitignore_specs(root_dir: Path, no_default_ignores: bool) -> Dict[Path, pathspec.PathSpec]: - """Retrieve gitignore specs for all .gitignore files found within root_dir.""" - if no_default_ignores: - return {} - - gitignore_specs = {} - try: - for dirpath_str, dirnames, filenames in os.walk(root_dir, topdown=True): - if ".git" in dirnames: - dirnames.remove(".git") - if ".gitignore" in filenames: - gitignore_path = Path(dirpath_str) / ".gitignore" - patterns = read_ignore_file(gitignore_path) - if patterns: - gitignore_specs[Path(dirpath_str)] = load_pathspec(patterns) - except OSError as e: - logging.warning(f"Error walking directory {root_dir} to find .gitignore files: {e}") - - return gitignore_specs - - -def should_ignore(relative_path_str: str, combined_spec: pathspec.PathSpec) -> bool: - """Check if a file or directory should be ignored based on combined pathspec.""" - is_ignored = combined_spec.match_file(relative_path_str) - logging.debug(f"Checking combined spec ignore for '{relative_path_str}': {is_ignored}") - return is_ignored +# src/treemapper/ignore.py +import fnmatch +import logging +import os +from pathlib import Path + +# ---> ИЗМЕНЕНИЕ: Добавляем импорты из typing <--- +from typing import Dict, List, Optional, Tuple + +# pathspec doesn't have type stubs +import pathspec # type: ignore + + +def read_ignore_file(file_path: Path) -> List[str]: + """Read the ignore patterns from the specified ignore file.""" + ignore_patterns = [] + if file_path.is_file(): + try: + # Try to read directly and handle all possible errors + with file_path.open("r", encoding="utf-8") as f: + ignore_patterns = [line.strip() for line in f if line.strip() and not line.startswith("#")] + logging.info(f"Using ignore patterns from {file_path}") + logging.debug(f"Read ignore patterns from {file_path}: {ignore_patterns}") + except PermissionError: + logging.warning(f"Could not read ignore file {file_path}: Permission denied") + except IOError as e: + logging.warning(f"Could not read ignore file {file_path}: {e}") + except UnicodeDecodeError as e: + logging.warning(f"Could not decode ignore file {file_path} as UTF-8: {e}") + return ignore_patterns + + +def load_pathspec(patterns: List[str], syntax="gitwildmatch") -> pathspec.PathSpec: + """Load pathspec from a list of patterns.""" + spec = pathspec.PathSpec.from_lines(syntax, patterns) + logging.debug(f"Loaded pathspec with patterns: {patterns}") + return spec + + +# ---> ИЗМЕНЕНИЕ: Заменяем | None на Optional[...] <--- +def get_ignore_specs( + root_dir: Path, + custom_ignore_file: Optional[Path] = None, + no_default_ignores: bool = False, + output_file: Optional[Path] = None, +) -> Tuple[pathspec.PathSpec, Dict[Path, pathspec.PathSpec]]: + """Get combined ignore specs and git ignore specs.""" + default_patterns = get_default_patterns(root_dir, no_default_ignores, output_file) + custom_patterns = get_custom_patterns(root_dir, custom_ignore_file) + + if no_default_ignores: + combined_patterns = custom_patterns + if output_file: + try: + resolved_output = output_file.resolve() + resolved_root = root_dir.resolve() + if resolved_output.is_relative_to(resolved_root): + relative_output_str = resolved_output.relative_to(resolved_root).as_posix() + output_pattern = f"/{relative_output_str}" + if output_pattern not in combined_patterns: + combined_patterns.append(output_pattern) + logging.debug(f"Adding output file to ignores (no_default_ignores=True): {output_pattern}") + except ValueError: + pass + except Exception as e: + logging.warning(f"Could not determine relative path for output file {output_file}: {e}") + else: + combined_patterns = default_patterns + custom_patterns + + logging.debug(f"Ignore specs params: no_default_ignores={no_default_ignores}") + logging.debug(f"Default patterns (used unless no_default_ignores): {default_patterns}") + logging.debug(f"Custom patterns (-i): {custom_patterns}") + logging.debug(f"Combined patterns for spec: {combined_patterns}") + + combined_spec = load_pathspec(combined_patterns) + gitignore_specs = get_gitignore_specs(root_dir, no_default_ignores) + + return combined_spec, gitignore_specs + + +# ---> ИЗМЕНЕНИЕ: Заменяем | None на Optional[...] <--- +def get_default_patterns(root_dir: Path, no_default_ignores: bool, output_file: Optional[Path]) -> List[str]: + """Retrieve default ignore patterns ONLY IF no_default_ignores is FALSE.""" + if no_default_ignores: + return [] + + # Add common patterns to default ignores + patterns = [ + "**/__pycache__/", + "**/*.py[cod]", + "**/*.so", + "**/.pytest_cache/", + "**/.coverage", + "**/.mypy_cache/", + "**/*.egg-info/", + "**/.git/", + "**/.eggs/", + ] + + treemapper_ignore_file = root_dir / ".treemapperignore" + patterns.extend(read_ignore_file(treemapper_ignore_file)) + + if output_file: + try: + resolved_output = output_file.resolve() + resolved_root = root_dir.resolve() + try: + relative_output = resolved_output.relative_to(resolved_root) + output_pattern = f"/{relative_output.as_posix()}" + patterns.append(output_pattern) + logging.debug(f"Adding output file to default ignores: {output_pattern}") + except ValueError: + logging.debug(f"Output file {output_file} is outside root directory {root_dir}, not adding to default ignores.") + except Exception as e: + logging.warning(f"Could not determine relative path for output file {output_file}: {e}") + + return patterns + + +# ---> ИЗМЕНЕНИЕ: Заменяем | None на Optional[...] <--- +def get_custom_patterns(root_dir: Path, custom_ignore_file: Optional[Path]) -> List[str]: + """Retrieve custom ignore patterns from the file specified with -i.""" + if not custom_ignore_file: + return [] + + if not custom_ignore_file.is_absolute(): + custom_ignore_file = Path.cwd() / custom_ignore_file + + if custom_ignore_file.is_file(): + return read_ignore_file(custom_ignore_file) + else: + logging.warning(f"Custom ignore file '{custom_ignore_file}' not found.") + return [] + + +def get_gitignore_specs(root_dir: Path, no_default_ignores: bool) -> Dict[Path, pathspec.PathSpec]: + """Retrieve gitignore specs for all .gitignore files found within root_dir.""" + if no_default_ignores: + return {} + + # Define common directories to always ignore regardless of ignore patterns + common_ignore_dirs = { + "__pycache__", + ".pytest_cache", + "node_modules", + ".git", + ".eggs", + "*.egg-info", + "dist", + "build", + ".tox", + ".coverage", + ".mypy_cache", + ".venv", + "venv", + "env", + } + + gitignore_specs = {} + try: + for dirpath_str, dirnames, filenames in os.walk(root_dir, topdown=True): + # Filter out directories we want to skip + for ignore_dir in list(dirnames): + # Skip directories that match common ignore patterns + if ignore_dir in common_ignore_dirs or any( + fnmatch.fnmatch(ignore_dir, pattern) for pattern in common_ignore_dirs + ): + dirnames.remove(ignore_dir) + logging.debug(f"Skipping ignored directory: {os.path.join(dirpath_str, ignore_dir)}") + + if ".gitignore" in filenames: + gitignore_path = Path(dirpath_str) / ".gitignore" + patterns = read_ignore_file(gitignore_path) + if patterns: + gitignore_specs[Path(dirpath_str)] = load_pathspec(patterns) + except OSError as e: + logging.warning(f"Error walking directory {root_dir} to find .gitignore files: {e}") + + return gitignore_specs + + +def should_ignore(relative_path_str: str, combined_spec: pathspec.PathSpec) -> bool: + """Check if a file or directory should be ignored based on combined pathspec.""" + is_ignored = combined_spec.match_file(relative_path_str) + logging.debug(f"Checking combined spec ignore for '{relative_path_str}': {is_ignored}") + return is_ignored diff --git a/src/treemapper/logger.py b/src/treemapper/logger.py index 08dabd49..2ccffec1 100644 --- a/src/treemapper/logger.py +++ b/src/treemapper/logger.py @@ -1,14 +1,14 @@ -import logging - - -def setup_logging(verbosity: int) -> None: - """Configure the logging level based on verbosity.""" - level_map = { - 0: logging.ERROR, - 1: logging.WARNING, - 2: logging.INFO, - 3: logging.DEBUG, - } - level = level_map.get(verbosity, logging.INFO) - - logging.basicConfig(level=level, format="%(levelname)s: %(message)s") +import logging + + +def setup_logging(verbosity: int) -> None: + """Configure the logging level based on verbosity.""" + level_map = { + 0: logging.ERROR, + 1: logging.WARNING, + 2: logging.INFO, + 3: logging.DEBUG, + } + level = level_map.get(verbosity, logging.INFO) + + logging.basicConfig(level=level, format="%(levelname)s: %(message)s") diff --git a/src/treemapper/tree.py b/src/treemapper/tree.py index 536f1eb4..d98761e7 100644 --- a/src/treemapper/tree.py +++ b/src/treemapper/tree.py @@ -1,127 +1,192 @@ -# src/treemapper/tree.py -import logging -from pathlib import Path -from typing import Any, Dict, List, Optional - -from pathspec import pathspec - -from .ignore import should_ignore - - -def build_tree( - dir_path: Path, base_dir: Path, combined_spec: pathspec.PathSpec, gitignore_specs: Dict[Path, pathspec.PathSpec] -) -> List[Dict[str, Any]]: - """Build the directory tree structure.""" - tree = [] - try: - for entry in sorted(dir_path.iterdir()): - try: - relative_path = entry.relative_to(base_dir).as_posix() - is_dir_entry = entry.is_dir() - except OSError as e: - logging.warning(f"Could not process path for entry {entry}: {e}") - continue - - if is_dir_entry: - relative_path_check = relative_path + "/" - else: - relative_path_check = relative_path - - if should_ignore(relative_path_check, combined_spec): - continue - - if should_ignore_git(entry, relative_path_check, gitignore_specs, base_dir): - continue - - if not entry.exists() or entry.is_symlink(): - logging.debug(f"Skipping '{relative_path_check}': not exists or is symlink") - continue - - node = create_node(entry, base_dir, combined_spec, gitignore_specs) - if node: - tree.append(node) - - except PermissionError: - logging.warning(f"Permission denied accessing directory {dir_path}") - except OSError as e: - logging.warning(f"Error accessing directory {dir_path}: {e}") - - return tree - - -def should_ignore_git( - entry: Path, relative_path_check: str, gitignore_specs: Dict[Path, pathspec.PathSpec], base_dir: Path -) -> bool: - """Check if entry should be ignored based on applicable gitignore specs.""" - if not gitignore_specs: - return False - - for git_dir_path, git_spec in gitignore_specs.items(): - try: - if entry == git_dir_path or entry.is_relative_to(git_dir_path): - rel_path_to_git_dir = entry.relative_to(git_dir_path).as_posix() - if entry.is_dir() and not rel_path_to_git_dir.endswith("/"): - rel_path_to_git_dir += "/" - - logging.debug( - f"Checking path '{rel_path_to_git_dir}' against spec from '{git_dir_path}' with patterns: {git_spec.patterns}" - ) - - if git_spec.match_file(rel_path_to_git_dir): - try: - gitignore_location = git_dir_path.relative_to(base_dir).as_posix() - if not gitignore_location: - gitignore_location = "." - except ValueError: - gitignore_location = str(git_dir_path) - logging.debug(f"Ignoring '{relative_path_check}' based on .gitignore in '{gitignore_location}'") - return True - except ValueError: - continue - except Exception as e: - logging.warning(f"Error checking gitignore spec from {git_dir_path} against {entry}: {e}") - continue - - return False - - -def create_node( - entry: Path, base_dir: Path, combined_spec: pathspec.PathSpec, gitignore_specs: Dict[Path, pathspec.PathSpec] -) -> Optional[Dict[str, Any]]: - """Create a node for the tree structure. Returns None if node creation fails.""" - try: - node_type = "directory" if entry.is_dir() else "file" - - node: Dict[str, Any] = {"name": entry.name, "type": node_type} - - if node_type == "directory": - children = build_tree(entry, base_dir, combined_spec, gitignore_specs) - if children: - node["children"] = children - elif node_type == "file": - - node_content: Optional[str] = None - try: - node_content = entry.read_text(encoding="utf-8") - if isinstance(node_content, str): - cleaned_content = node_content.replace("\x00", "") - if cleaned_content != node_content: - logging.warning(f"Removed NULL bytes from content of {entry.name}") - node_content = cleaned_content - except UnicodeDecodeError: - logging.warning(f"Cannot decode {entry.name} as UTF-8. Marking as unreadable.") - node_content = "" - except IOError as e_read: - logging.error(f"Could not read {entry.name}: {e_read}") - node_content = "" - except Exception as e_other: - logging.error(f"Unexpected error reading {entry.name}: {e_other}") - node_content = "" - - node["content"] = node_content if node_content is not None else "" - - return node - - except Exception as e: - logging.error(f"Failed to create node for {entry.name}: {e}") - return None +# src/treemapper/tree.py +import logging + +# os is used for permission checking +import os +from pathlib import Path +from typing import Any, Dict, List, Optional + +# pathspec doesn't have type stubs +from pathspec import pathspec # type: ignore + +from .ignore import should_ignore + + +def build_tree( + dir_path: Path, base_dir: Path, combined_spec: pathspec.PathSpec, gitignore_specs: Dict[Path, pathspec.PathSpec] +) -> List[Dict[str, Any]]: + """Build the directory tree structure.""" + tree = [] + try: + for entry in sorted(dir_path.iterdir()): + try: + relative_path = entry.relative_to(base_dir).as_posix() + is_dir_entry = entry.is_dir() + except OSError as e: + logging.warning(f"Could not process path for entry {entry}: {e}") + continue + + if is_dir_entry: + relative_path_check = relative_path + "/" + else: + relative_path_check = relative_path + + if should_ignore(relative_path_check, combined_spec): + continue + + if should_ignore_git(entry, relative_path_check, gitignore_specs, base_dir): + continue + + if not entry.exists() or entry.is_symlink(): + logging.debug(f"Skipping '{relative_path_check}': not exists or is symlink") + continue + + node = create_node(entry, base_dir, combined_spec, gitignore_specs) + if node: + tree.append(node) + + except PermissionError: + logging.warning(f"Permission denied accessing directory {dir_path}") + except OSError as e: + logging.warning(f"Error accessing directory {dir_path}: {e}") + + return tree + + +def should_ignore_git( + entry: Path, relative_path_check: str, gitignore_specs: Dict[Path, pathspec.PathSpec], base_dir: Path +) -> bool: + """Check if entry should be ignored based on applicable gitignore specs.""" + if not gitignore_specs: + return False + + # Track the path's ignore status through the hierarchy - start with not ignored + is_ignored = False + closest_rule_path = None + closest_rule_distance = float("inf") + + # Process in hierarchical order from root to most specific directory + # Sort gitignore_specs by path length to process parent directories first + sorted_specs = sorted(gitignore_specs.items(), key=lambda x: len(str(x[0]))) + + for git_dir_path, git_spec in sorted_specs: + try: + # Only process if this gitignore applies to the entry (entry is in/under gitignore dir) + if entry == git_dir_path or entry.is_relative_to(git_dir_path): + # Calculate distance between entry and this gitignore (0 = same dir, 1 = immediate child, etc.) + try: + distance = len(entry.relative_to(git_dir_path).parts) + except ValueError: + continue # Skip if entry is not relative to git_dir_path + + # Get path relative to the gitignore directory + rel_path_to_git_dir = entry.relative_to(git_dir_path).as_posix() + if entry.is_dir() and not rel_path_to_git_dir.endswith("/"): + rel_path_to_git_dir += "/" + + # If this is root dir and rel_path is empty, it should be "." + if not rel_path_to_git_dir: + rel_path_to_git_dir = "." + + # Check for special handling of root-anchored patterns + if git_dir_path == base_dir: + # Also check with leading slash for root-anchored patterns + anchored_path = "/" + rel_path_to_git_dir if not rel_path_to_git_dir.startswith("/") else rel_path_to_git_dir + logging.debug(f"Checking root .gitignore with anchored path '{anchored_path}'") + match_anchored = git_spec.match_file(anchored_path) + if match_anchored: + logging.debug(f"Path '{anchored_path}' matches root-anchored pattern") + is_ignored = True + closest_rule_path = git_dir_path + closest_rule_distance = distance + + # Regular gitignore match + logging.debug(f"Checking '{rel_path_to_git_dir}' against .gitignore in '{git_dir_path}'") + match_regular = git_spec.match_file(rel_path_to_git_dir) + + # Update status if this is a more specific rule (closer to the file) + if match_regular and distance <= closest_rule_distance: + # Check if this contains a negation pattern (negative patterns start with !) + has_negation = False + try: + has_negation = any( + hasattr(pattern, "pattern") and pattern.pattern.startswith("!") for pattern in git_spec.patterns + ) + except Exception: + # Ignore errors when checking for negation patterns + pass + + is_ignored = match_regular + closest_rule_path = git_dir_path + closest_rule_distance = distance + + if has_negation: + logging.debug(f"Path '{rel_path_to_git_dir}' handled by negation pattern in {git_dir_path}") + + logging.debug( + f"After checking .gitignore in '{git_dir_path}', path '{relative_path_check}' is_ignored={is_ignored}" + ) + except Exception as e: + logging.warning(f"Error checking gitignore spec from {git_dir_path} against {entry}: {e}") + continue + + if is_ignored and closest_rule_path is not None: + try: + gitignore_location = closest_rule_path.relative_to(base_dir).as_posix() or "." + except ValueError: + gitignore_location = str(closest_rule_path) + logging.debug(f"Ignoring '{relative_path_check}' based on .gitignore in '{gitignore_location}'") + + return is_ignored + + +def create_node( + entry: Path, base_dir: Path, combined_spec: pathspec.PathSpec, gitignore_specs: Dict[Path, pathspec.PathSpec] +) -> Optional[Dict[str, Any]]: + """Create a node for the tree structure. Returns None if node creation fails.""" + try: + node_type = "directory" if entry.is_dir() else "file" + + node: Dict[str, Any] = {"name": entry.name, "type": node_type} + + if node_type == "directory": + children = build_tree(entry, base_dir, combined_spec, gitignore_specs) + if children: + node["children"] = children + elif node_type == "file": + + node_content: Optional[str] = None + try: + # Try to read the file directly, and handle all possible errors + # Check permissions first using os.access + if not os.access(entry, os.R_OK): + logging.error(f"Could not read {entry.name}: Permission denied") + node_content = "" + else: + node_content = entry.read_text(encoding="utf-8") + if isinstance(node_content, str): + cleaned_content = node_content.replace("\x00", "") + if cleaned_content != node_content: + logging.warning(f"Removed NULL bytes from content of {entry.name}") + node_content = cleaned_content + except PermissionError: + # Explicitly handle permission errors + logging.error(f"Could not read {entry.name}: Permission denied") + node_content = "" + except UnicodeDecodeError: + logging.warning(f"Cannot decode {entry.name} as UTF-8. Marking as unreadable.") + node_content = "" + except IOError as e_read: + logging.error(f"Could not read {entry.name}: {e_read}") + node_content = "" + except Exception as e_other: + logging.error(f"Unexpected error reading {entry.name}: {e_other}") + node_content = "" + + node["content"] = node_content if node_content is not None else "" + + return node + + except Exception as e: + logging.error(f"Failed to create node for {entry.name}: {e}") + return None diff --git a/src/treemapper/version.py b/src/treemapper/version.py index bac10fec..ed9b5561 100644 --- a/src/treemapper/version.py +++ b/src/treemapper/version.py @@ -1 +1 @@ -__version__ = "1.0.0" # This will be replaced during the build process +__version__ = "1.0.0" # This will be replaced during the build process diff --git a/src/treemapper/writer.py b/src/treemapper/writer.py index 249a23ef..5b23e585 100644 --- a/src/treemapper/writer.py +++ b/src/treemapper/writer.py @@ -1,38 +1,64 @@ -import logging -from pathlib import Path -from typing import Any, Dict - - -def write_yaml_node(file, node: Dict[str, Any], indent: str = "") -> None: - """Write a node of the directory tree in YAML format.""" - file.write(f"{indent}- name: {node['name']}\n") - file.write(f"{indent} type: {node['type']}\n") - - if "content" in node: - file.write(f"{indent} content: |\n") - for line in node["content"].splitlines(): - file.write(f"{indent} {line}\n") - - if "children" in node and node["children"]: - file.write(f"{indent} children:\n") - for child in node["children"]: - write_yaml_node(file, child, indent + " ") - - -def write_tree_to_file(tree: Dict[str, Any], output_file: Path) -> None: - """Write the complete tree to a YAML file.""" - try: - - output_file.parent.mkdir(parents=True, exist_ok=True) - - with output_file.open("w", encoding="utf-8") as f: - f.write(f"name: {tree['name']}\n") - f.write(f"type: {tree['type']}\n") - if "children" in tree and tree["children"]: - f.write("children:\n") - for child in tree["children"]: - write_yaml_node(f, child, " ") - logging.info(f"Directory tree saved to {output_file}") - except IOError as e: - logging.error(f"Unable to write to file '{output_file}': {e}") - raise +import logging + +# os is used for access permission checking +import os +from pathlib import Path +from typing import Any, Dict + + +def write_yaml_node(file, node: Dict[str, Any], indent: str = "") -> None: + """Write a node of the directory tree in YAML format.""" + # Escape filename with double quotes and handle any special characters + # This prevents issues with filenames like 'true', 'false', numbers, or names with special chars + name = str(node["name"]).replace('"', '\\"') # Escape any double quotes in the name + file.write(f'{indent}- name: "{name}"\n') + file.write(f"{indent} type: {node['type']}\n") + + if "content" in node: + file.write(f"{indent} content: |\n") + for line in node["content"].splitlines(): + file.write(f"{indent} {line}\n") + + if "children" in node and node["children"]: + file.write(f"{indent} children:\n") + for child in node["children"]: + write_yaml_node(file, child, indent + " ") + + +def write_tree_to_file(tree: Dict[str, Any], output_file: Path) -> None: + """Write the complete tree to a YAML file.""" + try: + # Create parent directories if they don't exist + output_file.parent.mkdir(parents=True, exist_ok=True) + + # For directories, try an early write test + if output_file.is_dir(): + logging.error(f"Unable to write to file '{output_file}': Is a directory") + raise IOError(f"Is a directory: {output_file}") + + # Check write permissions using os.access + if not os.access(output_file.parent, os.W_OK): + logging.error(f"Unable to write to file '{output_file}': Permission denied for directory") + raise IOError(f"Permission denied for directory: {output_file.parent}") + + # Test write permissions directly by attempting to open the file + try: + test_handle = output_file.open("w", encoding="utf-8") + test_handle.close() + except (PermissionError, IOError): + logging.error(f"Unable to write to file '{output_file}': Permission denied") + raise IOError(f"Permission denied: {output_file}") + + with output_file.open("w", encoding="utf-8") as f: + # Properly quote the root name as well + name = str(tree["name"]).replace('"', '\\"') + f.write(f'name: "{name}"\n') + f.write(f"type: {tree['type']}\n") + if "children" in tree and tree["children"]: + f.write("children:\n") + for child in tree["children"]: + write_yaml_node(f, child, " ") + logging.info(f"Directory tree saved to {output_file}") + except IOError as e: + logging.error(f"Unable to write to file '{output_file}': {e}") + raise diff --git a/tests/conftest.py b/tests/conftest.py index 62e004eb..a1c3ab4c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,95 +1,109 @@ -# tests/conftest.py -import logging -import os -import sys -from pathlib import Path - -import pytest - - -# --- Фикстура для создания временного проекта --- -@pytest.fixture -def temp_project(tmp_path): - """Create a temporary project structure for testing.""" - temp_dir = tmp_path / "treemapper_test_project" - temp_dir.mkdir() - (temp_dir / "src").mkdir() - (temp_dir / "src" / "main.py").write_text("def main():\n print('hello')\n", encoding="utf-8") - (temp_dir / "src" / "test.py").write_text("def test():\n pass\n", encoding="utf-8") - (temp_dir / "docs").mkdir() - (temp_dir / "docs" / "readme.md").write_text("# Documentation\n", encoding="utf-8") - (temp_dir / "output").mkdir() - (temp_dir / ".git").mkdir() - (temp_dir / ".git" / "config").write_text("git config file", encoding="utf-8") - (temp_dir / ".gitignore").write_text("*.pyc\n__pycache__/\n", encoding="utf-8") - (temp_dir / ".treemapperignore").write_text("output/\n.git/\n", encoding="utf-8") - yield temp_dir - - -# --- Фикстура для запуска маппера --- -@pytest.fixture -def run_mapper(monkeypatch, temp_project): - """Helper to run treemapper with given args.""" - - def _run(args): - """Runs the main function with patched CWD and sys.argv.""" - with monkeypatch.context() as m: - m.chdir(temp_project) - m.setattr(sys, "argv", ["treemapper"] + args) - try: - from treemapper.treemapper import main - - main() - return True - except SystemExit as e: - if e.code != 0: - print(f"SystemExit caught with code: {e.code}") - return False - return True - except Exception as e: - print(f"Caught unexpected exception in run_mapper: {e}") - - return False - - return _run - - -# ---> НАЧАЛО: Перенесенная фикстура set_perms <--- -@pytest.fixture -def set_perms(request): - """Fixture to temporarily set file/directory permissions (non-Windows).""" - original_perms = {} - paths_changed = [] - - def _set_perms(path: Path, perms: int): - if sys.platform == "win32": - pytest.skip("Permission tests skipped on Windows.") - if not path.exists(): - pytest.skip(f"Path does not exist, cannot set permissions: {path}") - try: - original_perms[path] = path.stat().st_mode - paths_changed.append(path) - os.chmod(path, perms) - logging.debug(f"Set permissions {oct(perms)} for {path}") - except OSError as e: - pytest.skip(f"Could not set permissions on {path}: {e}. Skipping test.") - - yield _set_perms - - logging.debug(f"Cleaning up permissions for: {paths_changed}") - for path in paths_changed: - - if path.exists() and path in original_perms: - try: - - if original_perms[path] is not None: - os.chmod(path, original_perms[path]) - logging.debug(f"Restored permissions for {path}") - else: - logging.warning(f"Original permissions for {path} were None, not restoring.") - except OSError as e: - - logging.warning(f"Could not restore permissions for {path}: {e}") - - -# ---> КОНЕЦ: Перенесенная фикстура set_perms <--- +# tests/conftest.py +import logging +import os +import sys +from pathlib import Path + +import pytest + + +# --- Фикстура для создания временного проекта --- +@pytest.fixture +def temp_project(tmp_path): + """Create a temporary project structure for testing.""" + temp_dir = tmp_path / "treemapper_test_project" + temp_dir.mkdir() + (temp_dir / "src").mkdir() + (temp_dir / "src" / "main.py").write_text("def main():\n print('hello')\n", encoding="utf-8") + (temp_dir / "src" / "test.py").write_text("def test():\n pass\n", encoding="utf-8") + (temp_dir / "docs").mkdir() + (temp_dir / "docs" / "readme.md").write_text("# Documentation\n", encoding="utf-8") + (temp_dir / "output").mkdir() + (temp_dir / ".git").mkdir() + (temp_dir / ".git" / "config").write_text("git config file", encoding="utf-8") + (temp_dir / ".gitignore").write_text("*.pyc\n__pycache__/\n", encoding="utf-8") + (temp_dir / ".treemapperignore").write_text("output/\n.git/\n", encoding="utf-8") + yield temp_dir + + +# --- Фикстура для запуска маппера --- +@pytest.fixture +def run_mapper(monkeypatch, temp_project): + """Helper to run treemapper with given args.""" + + def _run(args): + """Runs the main function with patched CWD and sys.argv.""" + with monkeypatch.context() as m: + m.chdir(temp_project) + m.setattr(sys, "argv", ["treemapper"] + args) + try: + from treemapper.treemapper import main + + main() + return True + except SystemExit as e: + if e.code != 0: + print(f"SystemExit caught with code: {e.code}") + return False + return True + except Exception as e: + print(f"Caught unexpected exception in run_mapper: {e}") + + return False + + return _run + + +# ---> НАЧАЛО: Перенесенная фикстура set_perms <--- +@pytest.fixture +def set_perms(request): + """Fixture to temporarily set file/directory permissions (non-Windows).""" + original_perms = {} + paths_changed = [] + + def _set_perms(path: Path, perms: int): + if sys.platform == "win32": + pytest.skip("Permission tests skipped on Windows.") + + # Check if running in WSL environment + is_wsl = False + try: + with open("/proc/version", "r") as f: + if "microsoft" in f.read().lower(): + is_wsl = True + except FileNotFoundError: + pass + + # Skip tests on Windows paths in WSL + if is_wsl and "/mnt/" in str(path): + pytest.skip(f"Permission tests skipped on Windows-mounted paths in WSL: {path}") + + if not path.exists(): + pytest.skip(f"Path does not exist, cannot set permissions: {path}") + try: + original_perms[path] = path.stat().st_mode + paths_changed.append(path) + os.chmod(path, perms) + logging.debug(f"Set permissions {oct(perms)} for {path}") + except OSError as e: + pytest.skip(f"Could not set permissions on {path}: {e}. Skipping test.") + + yield _set_perms + + logging.debug(f"Cleaning up permissions for: {paths_changed}") + for path in paths_changed: + + if path.exists() and path in original_perms: + try: + + if original_perms[path] is not None: + os.chmod(path, original_perms[path]) + logging.debug(f"Restored permissions for {path}") + else: + logging.warning(f"Original permissions for {path} were None, not restoring.") + except OSError as e: + + logging.warning(f"Could not restore permissions for {path}: {e}") + + +# ---> КОНЕЦ: Перенесенная фикстура set_perms <--- diff --git a/tests/test_anchored_patterns.py b/tests/test_anchored_patterns.py new file mode 100644 index 00000000..32cadd49 --- /dev/null +++ b/tests/test_anchored_patterns.py @@ -0,0 +1,161 @@ +# tests/test_anchored_patterns.py +import sys # Required for platform checks +from pathlib import Path # Path operations in tests + +import pytest # Used for test decorators + +from .utils import find_node_by_path, get_all_files_in_tree, load_yaml + + +# Skip tests on Windows since they may behave differently +skip_on_windows = pytest.mark.skipif(sys.platform == "win32", reason="Skipping on Windows") + + +# Check path resolution with Path +def _is_valid_path(p: str) -> bool: + """Verify path is valid using Path.""" + return Path(p).exists() + + +def test_anchored_pattern_fix(temp_project, run_mapper): + """Test for the fixed handling of anchored patterns in gitignore.""" + # Create a clean directory for this test + test_dir = temp_project / "anchored_test_dir" + test_dir.mkdir() + + # Create test files + (test_dir / "root_file.txt").touch() + (test_dir / "subdir").mkdir() + (test_dir / "subdir" / "nested_file.txt").touch() + (test_dir / "subdir" / "root_file.txt").touch() # Same name as root file + + # Create gitignore with anchored pattern to ignore only root file + (test_dir / ".gitignore").write_text("/root_file.txt\n") + + # Run TreeMapper on the clean test directory + output_path = temp_project / "anchored_output.yaml" + assert run_mapper([str(test_dir), "-o", str(output_path)]) + result = load_yaml(output_path) + + # The root should be the test directory + assert result["name"] == test_dir.name + + # Check if anchored pattern correctly ignores only root file + all_files = get_all_files_in_tree(result) + + # .gitignore should be included + assert ".gitignore" in all_files + + # Root file should be ignored + root_node = find_node_by_path(result, ["root_file.txt"]) + assert root_node is None, "Anchored pattern failed to ignore root file" + + # Files in subdirectories shouldn't be affected by anchored pattern + subdir_node = find_node_by_path(result, ["subdir"]) + assert subdir_node is not None, "Subdir not found in output" + + # Direct check for subdir/root_file.txt + nested_file = find_node_by_path(result, ["subdir", "root_file.txt"]) + assert nested_file is not None, "Anchored pattern incorrectly ignored file in subdirectory" + + +def test_non_anchored_pattern(temp_project, run_mapper): + """Test non-anchored pattern that should ignore files in all directories.""" + # Create a clean directory for this test + test_dir = temp_project / "non_anchored_test_dir" + test_dir.mkdir() + + # Create test files + (test_dir / "file.log").touch() + (test_dir / "subdir").mkdir() + (test_dir / "subdir" / "file.log").touch() + (test_dir / "subdir" / "data.txt").touch() + (test_dir / "data.txt").touch() + + # Create gitignore with non-anchored pattern + (test_dir / ".gitignore").write_text("*.log\n") + + # Run TreeMapper + output_path = temp_project / "non_anchored_output.yaml" + assert run_mapper([str(test_dir), "-o", str(output_path)]) + result = load_yaml(output_path) + + # The root should be the test directory + assert result["name"] == test_dir.name + + # Log files should be ignored in all directories + log_file = find_node_by_path(result, ["file.log"]) + assert log_file is None, "Non-anchored pattern failed to ignore root log file" + + # Regular files should be included + data_txt = find_node_by_path(result, ["data.txt"]) + assert data_txt is not None, "Regular file incorrectly ignored" + + # Check nested directory + subdir_node = find_node_by_path(result, ["subdir"]) + assert subdir_node is not None + + # Nested log file should be ignored + nested_log = find_node_by_path(result, ["subdir", "file.log"]) + assert nested_log is None, "Non-anchored pattern failed to ignore nested file" + + # Nested regular file should be included + nested_txt = find_node_by_path(result, ["subdir", "data.txt"]) + assert nested_txt is not None, "Regular file incorrectly ignored" + + +def test_combined_patterns(temp_project, run_mapper): + """Test combination of anchored and non-anchored patterns.""" + # Create a clean directory for this test + test_dir = temp_project / "combined_test_dir" + test_dir.mkdir() + + # Create test files + (test_dir / "root_only.txt").touch() + (test_dir / "all_dirs.log").touch() + (test_dir / "regular.txt").touch() + (test_dir / "subdir").mkdir() + (test_dir / "subdir" / "root_only.txt").touch() + (test_dir / "subdir" / "all_dirs.log").touch() + (test_dir / "subdir" / "regular.txt").touch() + + # Create gitignore with anchored and non-anchored patterns + (test_dir / ".gitignore").write_text("/root_only.txt\n*.log\n") + + # Run TreeMapper + output_path = temp_project / "combined_output.yaml" + assert run_mapper([str(test_dir), "-o", str(output_path)]) + result = load_yaml(output_path) + + # The root should be the test directory + assert result["name"] == test_dir.name + + # Check gitignore effects + + # Root anchored file should be ignored + root_only = find_node_by_path(result, ["root_only.txt"]) + assert root_only is None, "Anchored pattern failed to ignore root file" + + # Log files should be ignored in all directories + all_dirs_log = find_node_by_path(result, ["all_dirs.log"]) + assert all_dirs_log is None, "Non-anchored pattern failed to ignore root log file" + + # Regular files should be included + regular_txt = find_node_by_path(result, ["regular.txt"]) + assert regular_txt is not None, "Regular file incorrectly ignored" + + # Check nested directory + subdir_node = find_node_by_path(result, ["subdir"]) + assert subdir_node is not None + + # Nested "root_only.txt" should NOT be ignored by anchored pattern + nested_root_only = find_node_by_path(result, ["subdir", "root_only.txt"]) + assert nested_root_only is not None, "Anchored pattern incorrectly ignored file in subdirectory" + + # Nested log file should be ignored + nested_log = find_node_by_path(result, ["subdir", "all_dirs.log"]) + assert nested_log is None, "Non-anchored pattern failed to ignore nested file" + + # Nested regular file should be included + nested_txt = find_node_by_path(result, ["subdir", "regular.txt"]) + assert nested_txt is not None, "Regular file incorrectly ignored" diff --git a/tests/test_basic.py b/tests/test_basic.py index 567ebc1a..3116bbb1 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,274 +1,274 @@ -# tests/test_basic.py -import logging -import shutil -import sys - -import pytest - -from .utils import find_node_by_path, get_all_files_in_tree, load_yaml, make_hashable - - -def normalize_content(content): - """Normalize content by stripping leading/trailing whitespace but keeping internal structure.""" - if content is None: - return None - return content - - -def normalize_tree(tree): - """Normalize tree for comparison by sorting children.""" - - if isinstance(tree, dict): - result = tree.copy() - if "children" in result and isinstance(result["children"], list): - result["children"] = sorted( - [normalize_tree(child) for child in result["children"] if isinstance(child, dict)], - key=lambda x: x.get("name", ""), - ) - return result - return tree - - -def test_basic_mapping(temp_project, run_mapper): - """Test basic directory mapping with default settings.""" - assert run_mapper(["."]) - output_file = temp_project / "directory_tree.yaml" - assert output_file.exists() - result = load_yaml(output_file) - assert result["type"] == "directory" - assert result["name"] == temp_project.name - all_files = get_all_files_in_tree(result) - assert "src" in all_files - assert "main.py" in all_files - assert "test.py" in all_files - assert "docs" in all_files - assert ".git" not in all_files, ".git should be ignored by default .treemapperignore" - assert "output" not in all_files - assert "directory_tree.yaml" not in all_files - - -def test_directory_content(temp_project, run_mapper): - """Test directory structure and content preservation.""" - assert run_mapper(["."]) - result = load_yaml(temp_project / "directory_tree.yaml") - src_dir = find_node_by_path(result, ["src"]) - assert src_dir is not None and src_dir["type"] == "directory" - main_py = find_node_by_path(src_dir, ["main.py"]) - assert main_py is not None and main_py["type"] == "file" - expected_main_content = "def main():\n print('hello')\n" - assert main_py.get("content") == expected_main_content - - -def test_custom_output(temp_project, run_mapper): - """Test custom output file locations and names.""" - output_path1 = temp_project / "custom.yaml" - assert run_mapper([".", "-o", str(output_path1)]) - assert output_path1.exists() - - subdir = temp_project / "subdir" - subdir.mkdir() - output_path2 = subdir / "output.yaml" - assert run_mapper([".", "-o", str(output_path2)]) - assert output_path2.exists() - - result1 = load_yaml(output_path1) - result2 = load_yaml(output_path2) - - assert find_node_by_path(result1, ["src", "main.py"]) is not None - assert find_node_by_path(result2, ["src", "main.py"]) is not None - assert find_node_by_path(result1, ["docs", "readme.md"]) is not None - assert find_node_by_path(result2, ["docs", "readme.md"]) is not None - assert find_node_by_path(result1, ["custom.yaml"]) is None - assert find_node_by_path(result2, ["output.yaml"]) is None - assert find_node_by_path(result2, ["subdir", "output.yaml"]) is None - - -def test_file_content_encoding(temp_project, run_mapper): - """Test handling of different file encodings and content.""" - ascii_content_orig = "Hello World" - multiline_content_orig = "line1\nline2\nline3" - empty_content_orig = "" - - (temp_project / "ascii.txt").write_text(ascii_content_orig) - (temp_project / "multiline.txt").write_text(multiline_content_orig) - (temp_project / "empty.txt").write_text(empty_content_orig) - - assert run_mapper(["."]) - result = load_yaml(temp_project / "directory_tree.yaml") - - ascii_node = find_node_by_path(result, ["ascii.txt"]) - multiline_node = find_node_by_path(result, ["multiline.txt"]) - empty_node = find_node_by_path(result, ["empty.txt"]) - - assert ascii_node is not None and ascii_node.get("content") == ascii_content_orig + "\n" - assert multiline_node is not None and multiline_node.get("content") == multiline_content_orig + "\n" - assert empty_node is not None and empty_node.get("content") == empty_content_orig - - -def test_nested_structures(temp_project, run_mapper): - """Test handling of deeply nested directory structures.""" - current = temp_project - contents = {} - for i in range(5): - current = current / f"level{i}" - current.mkdir() - content_str = f"Content {i}" - contents[i] = content_str - (current / f"file{i}.txt").write_text(content_str) - - assert run_mapper(["."]) - result = load_yaml(temp_project / "directory_tree.yaml") - - current_node = result - for i in range(5): - level_dir_node = find_node_by_path(current_node, [f"level{i}"]) - assert level_dir_node is not None, f"Level {i} directory not found" - assert level_dir_node.get("type") == "directory" - - level_file_node = find_node_by_path(level_dir_node, [f"file{i}.txt"]) - assert level_file_node is not None, f"File {i} in level {i} not found" - assert level_file_node.get("type") == "file" - - expected_content = contents[i] + "\n" - assert level_file_node.get("content") == expected_content - current_node = level_dir_node - - -def test_absolute_relative_paths(temp_project, run_mapper): - """Test handling of absolute and relative paths.""" - output_path_abs = temp_project / "abs_output.yaml" - output_path_rel_src = temp_project / "src.yaml" - output_path_rel_root = temp_project / "root.yaml" - - assert run_mapper([str(temp_project.absolute()), "-o", str(output_path_abs)]) - assert output_path_abs.exists() - assert run_mapper(["./src", "-o", str(output_path_rel_src)]) - assert output_path_rel_src.exists() - assert run_mapper([".", "-o", str(output_path_rel_root)]) - assert output_path_rel_root.exists() - - abs_result = load_yaml(output_path_abs) - src_result = load_yaml(output_path_rel_src) - root_result = load_yaml(output_path_rel_root) - - src_node_in_root = find_node_by_path(root_result, ["src"]) - assert src_node_in_root is not None - - output_files_to_ignore_src = {output_path_rel_src.name} - src_children_set = { - make_hashable(child) for child in src_result.get("children", []) if child.get("name") not in output_files_to_ignore_src - } - src_node_children_set = {make_hashable(child) for child in src_node_in_root.get("children", [])} - assert src_children_set == src_node_children_set - - output_files_to_ignore_root = { - output_path_abs.name, - output_path_rel_root.name, - output_path_rel_src.name, - } - abs_children_set = { - make_hashable(child) for child in abs_result.get("children", []) if child.get("name") not in output_files_to_ignore_root - } - root_children_set = { - make_hashable(child) for child in root_result.get("children", []) if child.get("name") not in output_files_to_ignore_root - } - assert abs_children_set == root_children_set, f"Set difference: {abs_children_set.symmetric_difference(root_children_set)}" - - -def test_output_handling(temp_project, run_mapper): - """Test various output file scenarios.""" - output_file_overwrite = temp_project / "output_overwrite.yaml" - output_file_overwrite.write_text("original content") - assert run_mapper([".", "-o", str(output_file_overwrite)]) - assert "original content" not in output_file_overwrite.read_text() - assert load_yaml(output_file_overwrite) is not None - - new_dir = temp_project / "new_dir_created_by_writer" - output_path_new_dir = new_dir / "tree.yaml" - if new_dir.exists(): - shutil.rmtree(new_dir) - assert run_mapper([".", "-o", str(output_path_new_dir)]) - assert output_path_new_dir.exists() - assert new_dir.is_dir() - assert load_yaml(output_path_new_dir) is not None - - -WIN_SKIP_MSG = "Skipping unicode filename test on Windows (potential FS issues)" - - -@pytest.mark.skipif(sys.platform == "win32", reason=WIN_SKIP_MSG) -def test_unicode_filenames(temp_project, run_mapper): - """Тест: файлы и директории с Unicode именами.""" - (temp_project / "привет_мир").mkdir() - (temp_project / "привет_мир" / "файл.txt").write_text("содержимое", encoding="utf-8") - (temp_project / "你好.txt").write_text("世界", encoding="utf-8") - (temp_project / "📄").touch() - - output_path = temp_project / "unicode_names_output.yaml" - assert run_mapper([".", "-o", str(output_path)]) - result = load_yaml(output_path) - all_names = get_all_files_in_tree(result) - - assert "привет_мир" in all_names - assert "файл.txt" in all_names - assert "你好.txt" in all_names - assert "📄" in all_names - - nihao_node = find_node_by_path(result, ["你好.txt"]) - assert nihao_node is not None - - assert nihao_node.get("content") == "世界\n" - - privet_file_node = find_node_by_path(result, ["привет_мир", "файл.txt"]) - assert privet_file_node is not None - - assert privet_file_node.get("content") == "содержимое\n" - - -def test_unicode_content_and_encoding_errors(temp_project, run_mapper, caplog): - """Тест: содержимое в UTF-8, не-UTF8 (CP1251), бинарное.""" - utf8_content = "привет мир" - try: - cp1251_content_bytes = "тест".encode("cp1251") - except LookupError: - pytest.skip("CP1251 codec not found, skipping test") - binary_content = b"\x00\x81\x9f\xff" - - (temp_project / "utf8.txt").write_text(utf8_content, encoding="utf-8") - (temp_project / "cp1251.txt").write_bytes(cp1251_content_bytes) - (temp_project / "binary.bin").write_bytes(binary_content) - - output_path = temp_project / "encodings_output.yaml" - with caplog.at_level(logging.WARNING): - assert run_mapper([".", "-o", str(output_path)]) - result = load_yaml(output_path) - - utf8_node = find_node_by_path(result, ["utf8.txt"]) - cp1251_node = find_node_by_path(result, ["cp1251.txt"]) - binary_node = find_node_by_path(result, ["binary.bin"]) - - assert utf8_node is not None, "'utf8.txt' not found" - - assert utf8_node.get("content") == utf8_content + "\n" - - assert cp1251_node is not None, "'cp1251.txt' not found" - - assert cp1251_node.get("content") == "\n" - assert any( - "Cannot decode cp1251.txt as UTF-8" in record.message for record in caplog.records if record.levelno >= logging.WARNING - ), "Expected WARNING log message about decoding failure not found for cp1251.txt" - - assert binary_node is not None, "'binary.bin' not found" - - assert isinstance(binary_node.get("content"), str) - assert binary_node.get("content", "").startswith("= logging.WARNING) - or ("Could not read binary.bin" in record.message and record.levelno >= logging.ERROR) - or ("Unexpected error reading binary.bin" in record.message and record.levelno >= logging.ERROR) - or ("Removed NULL bytes from content of binary.bin" in record.message and record.levelno >= logging.WARNING) - for record in caplog.records - ), "Expected WARNING/ERROR log message about reading/decoding failure not found for binary.bin" +# tests/test_basic.py +import logging +import shutil +import sys + +import pytest + +from .utils import find_node_by_path, get_all_files_in_tree, load_yaml, make_hashable + + +def normalize_content(content): + """Normalize content by stripping leading/trailing whitespace but keeping internal structure.""" + if content is None: + return None + return content + + +def normalize_tree(tree): + """Normalize tree for comparison by sorting children.""" + + if isinstance(tree, dict): + result = tree.copy() + if "children" in result and isinstance(result["children"], list): + result["children"] = sorted( + [normalize_tree(child) for child in result["children"] if isinstance(child, dict)], + key=lambda x: x.get("name", ""), + ) + return result + return tree + + +def test_basic_mapping(temp_project, run_mapper): + """Test basic directory mapping with default settings.""" + assert run_mapper(["."]) + output_file = temp_project / "directory_tree.yaml" + assert output_file.exists() + result = load_yaml(output_file) + assert result["type"] == "directory" + assert result["name"] == temp_project.name + all_files = get_all_files_in_tree(result) + assert "src" in all_files + assert "main.py" in all_files + assert "test.py" in all_files + assert "docs" in all_files + assert ".git" not in all_files, ".git should be ignored by default .treemapperignore" + assert "output" not in all_files + assert "directory_tree.yaml" not in all_files + + +def test_directory_content(temp_project, run_mapper): + """Test directory structure and content preservation.""" + assert run_mapper(["."]) + result = load_yaml(temp_project / "directory_tree.yaml") + src_dir = find_node_by_path(result, ["src"]) + assert src_dir is not None and src_dir["type"] == "directory" + main_py = find_node_by_path(src_dir, ["main.py"]) + assert main_py is not None and main_py["type"] == "file" + expected_main_content = "def main():\n print('hello')\n" + assert main_py.get("content") == expected_main_content + + +def test_custom_output(temp_project, run_mapper): + """Test custom output file locations and names.""" + output_path1 = temp_project / "custom.yaml" + assert run_mapper([".", "-o", str(output_path1)]) + assert output_path1.exists() + + subdir = temp_project / "subdir" + subdir.mkdir() + output_path2 = subdir / "output.yaml" + assert run_mapper([".", "-o", str(output_path2)]) + assert output_path2.exists() + + result1 = load_yaml(output_path1) + result2 = load_yaml(output_path2) + + assert find_node_by_path(result1, ["src", "main.py"]) is not None + assert find_node_by_path(result2, ["src", "main.py"]) is not None + assert find_node_by_path(result1, ["docs", "readme.md"]) is not None + assert find_node_by_path(result2, ["docs", "readme.md"]) is not None + assert find_node_by_path(result1, ["custom.yaml"]) is None + assert find_node_by_path(result2, ["output.yaml"]) is None + assert find_node_by_path(result2, ["subdir", "output.yaml"]) is None + + +def test_file_content_encoding(temp_project, run_mapper): + """Test handling of different file encodings and content.""" + ascii_content_orig = "Hello World" + multiline_content_orig = "line1\nline2\nline3" + empty_content_orig = "" + + (temp_project / "ascii.txt").write_text(ascii_content_orig) + (temp_project / "multiline.txt").write_text(multiline_content_orig) + (temp_project / "empty.txt").write_text(empty_content_orig) + + assert run_mapper(["."]) + result = load_yaml(temp_project / "directory_tree.yaml") + + ascii_node = find_node_by_path(result, ["ascii.txt"]) + multiline_node = find_node_by_path(result, ["multiline.txt"]) + empty_node = find_node_by_path(result, ["empty.txt"]) + + assert ascii_node is not None and ascii_node.get("content") == ascii_content_orig + "\n" + assert multiline_node is not None and multiline_node.get("content") == multiline_content_orig + "\n" + assert empty_node is not None and empty_node.get("content") == empty_content_orig + + +def test_nested_structures(temp_project, run_mapper): + """Test handling of deeply nested directory structures.""" + current = temp_project + contents = {} + for i in range(5): + current = current / f"level{i}" + current.mkdir() + content_str = f"Content {i}" + contents[i] = content_str + (current / f"file{i}.txt").write_text(content_str) + + assert run_mapper(["."]) + result = load_yaml(temp_project / "directory_tree.yaml") + + current_node = result + for i in range(5): + level_dir_node = find_node_by_path(current_node, [f"level{i}"]) + assert level_dir_node is not None, f"Level {i} directory not found" + assert level_dir_node.get("type") == "directory" + + level_file_node = find_node_by_path(level_dir_node, [f"file{i}.txt"]) + assert level_file_node is not None, f"File {i} in level {i} not found" + assert level_file_node.get("type") == "file" + + expected_content = contents[i] + "\n" + assert level_file_node.get("content") == expected_content + current_node = level_dir_node + + +def test_absolute_relative_paths(temp_project, run_mapper): + """Test handling of absolute and relative paths.""" + output_path_abs = temp_project / "abs_output.yaml" + output_path_rel_src = temp_project / "src.yaml" + output_path_rel_root = temp_project / "root.yaml" + + assert run_mapper([str(temp_project.absolute()), "-o", str(output_path_abs)]) + assert output_path_abs.exists() + assert run_mapper(["./src", "-o", str(output_path_rel_src)]) + assert output_path_rel_src.exists() + assert run_mapper([".", "-o", str(output_path_rel_root)]) + assert output_path_rel_root.exists() + + abs_result = load_yaml(output_path_abs) + src_result = load_yaml(output_path_rel_src) + root_result = load_yaml(output_path_rel_root) + + src_node_in_root = find_node_by_path(root_result, ["src"]) + assert src_node_in_root is not None + + output_files_to_ignore_src = {output_path_rel_src.name} + src_children_set = { + make_hashable(child) for child in src_result.get("children", []) if child.get("name") not in output_files_to_ignore_src + } + src_node_children_set = {make_hashable(child) for child in src_node_in_root.get("children", [])} + assert src_children_set == src_node_children_set + + output_files_to_ignore_root = { + output_path_abs.name, + output_path_rel_root.name, + output_path_rel_src.name, + } + abs_children_set = { + make_hashable(child) for child in abs_result.get("children", []) if child.get("name") not in output_files_to_ignore_root + } + root_children_set = { + make_hashable(child) for child in root_result.get("children", []) if child.get("name") not in output_files_to_ignore_root + } + assert abs_children_set == root_children_set, f"Set difference: {abs_children_set.symmetric_difference(root_children_set)}" + + +def test_output_handling(temp_project, run_mapper): + """Test various output file scenarios.""" + output_file_overwrite = temp_project / "output_overwrite.yaml" + output_file_overwrite.write_text("original content") + assert run_mapper([".", "-o", str(output_file_overwrite)]) + assert "original content" not in output_file_overwrite.read_text() + assert load_yaml(output_file_overwrite) is not None + + new_dir = temp_project / "new_dir_created_by_writer" + output_path_new_dir = new_dir / "tree.yaml" + if new_dir.exists(): + shutil.rmtree(new_dir) + assert run_mapper([".", "-o", str(output_path_new_dir)]) + assert output_path_new_dir.exists() + assert new_dir.is_dir() + assert load_yaml(output_path_new_dir) is not None + + +WIN_SKIP_MSG = "Skipping unicode filename test on Windows (potential FS issues)" + + +@pytest.mark.skipif(sys.platform == "win32", reason=WIN_SKIP_MSG) +def test_unicode_filenames(temp_project, run_mapper): + """Тест: файлы и директории с Unicode именами.""" + (temp_project / "привет_мир").mkdir() + (temp_project / "привет_мир" / "файл.txt").write_text("содержимое", encoding="utf-8") + (temp_project / "你好.txt").write_text("世界", encoding="utf-8") + (temp_project / "📄").touch() + + output_path = temp_project / "unicode_names_output.yaml" + assert run_mapper([".", "-o", str(output_path)]) + result = load_yaml(output_path) + all_names = get_all_files_in_tree(result) + + assert "привет_мир" in all_names + assert "файл.txt" in all_names + assert "你好.txt" in all_names + assert "📄" in all_names + + nihao_node = find_node_by_path(result, ["你好.txt"]) + assert nihao_node is not None + + assert nihao_node.get("content") == "世界\n" + + privet_file_node = find_node_by_path(result, ["привет_мир", "файл.txt"]) + assert privet_file_node is not None + + assert privet_file_node.get("content") == "содержимое\n" + + +def test_unicode_content_and_encoding_errors(temp_project, run_mapper, caplog): + """Тест: содержимое в UTF-8, не-UTF8 (CP1251), бинарное.""" + utf8_content = "привет мир" + try: + cp1251_content_bytes = "тест".encode("cp1251") + except LookupError: + pytest.skip("CP1251 codec not found, skipping test") + binary_content = b"\x00\x81\x9f\xff" + + (temp_project / "utf8.txt").write_text(utf8_content, encoding="utf-8") + (temp_project / "cp1251.txt").write_bytes(cp1251_content_bytes) + (temp_project / "binary.bin").write_bytes(binary_content) + + output_path = temp_project / "encodings_output.yaml" + with caplog.at_level(logging.WARNING): + assert run_mapper([".", "-o", str(output_path)]) + result = load_yaml(output_path) + + utf8_node = find_node_by_path(result, ["utf8.txt"]) + cp1251_node = find_node_by_path(result, ["cp1251.txt"]) + binary_node = find_node_by_path(result, ["binary.bin"]) + + assert utf8_node is not None, "'utf8.txt' not found" + + assert utf8_node.get("content") == utf8_content + "\n" + + assert cp1251_node is not None, "'cp1251.txt' not found" + + assert cp1251_node.get("content") == "\n" + assert any( + "Cannot decode cp1251.txt as UTF-8" in record.message for record in caplog.records if record.levelno >= logging.WARNING + ), "Expected WARNING log message about decoding failure not found for cp1251.txt" + + assert binary_node is not None, "'binary.bin' not found" + + assert isinstance(binary_node.get("content"), str) + assert binary_node.get("content", "").startswith("= logging.WARNING) + or ("Could not read binary.bin" in record.message and record.levelno >= logging.ERROR) + or ("Unexpected error reading binary.bin" in record.message and record.levelno >= logging.ERROR) + or ("Removed NULL bytes from content of binary.bin" in record.message and record.levelno >= logging.WARNING) + for record in caplog.records + ), "Expected WARNING/ERROR log message about reading/decoding failure not found for binary.bin" diff --git a/tests/test_cli.py b/tests/test_cli.py index cd06fe82..9d463eb6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,55 +1,55 @@ -# tests/test_cli.py -import subprocess -import sys - -import pytest - -PYTHON_EXEC = sys.executable - - -def run_cli_command(args, cwd): - """Запускает treemapper как отдельный процесс""" - command = [PYTHON_EXEC, "-m", "treemapper"] + args - - result = subprocess.run(command, capture_output=True, text=True, cwd=cwd, encoding="utf-8", errors="replace") - return result - - -def test_cli_help_short(temp_project): - """Тест: вызов справки через -h""" - result = run_cli_command(["-h"], cwd=temp_project) - assert result.returncode == 0 - assert "usage: treemapper" in result.stdout.lower() - assert "--help" in result.stdout - assert "--output-file" in result.stdout - assert "--verbosity" in result.stdout - - -def test_cli_help_long(temp_project): - """Тест: вызов справки через --help""" - result = run_cli_command(["--help"], cwd=temp_project) - assert result.returncode == 0 - assert "usage: treemapper" in result.stdout.lower() - assert "--help" in result.stdout - - -def test_cli_invalid_verbosity(temp_project): - """Тест: неверный уровень verbosity""" - result = run_cli_command(["-v", "5"], cwd=temp_project) - assert result.returncode != 0 - - assert ( - "invalid choice: '5'" in result.stderr or "invalid choice: 5" in result.stderr - ), f"stderr does not contain expected invalid choice message for '5'. stderr: {result.stderr}" - - result_neg = run_cli_command(["-v", "-1"], cwd=temp_project) - assert result_neg.returncode != 0 - - assert ( - "invalid choice: '-1'" in result_neg.stderr or "invalid choice: -1" in result_neg.stderr - ), f"stderr does not contain expected invalid choice message for '-1'. stderr: {result_neg.stderr}" - - -def test_cli_version_display(temp_project): - """Тест: отображение версии (если будет добавлено)""" - pytest.skip("Version display option ('--version') not implemented yet.") +# tests/test_cli.py +import subprocess +import sys + +import pytest + +PYTHON_EXEC = sys.executable + + +def run_cli_command(args, cwd): + """Запускает treemapper как отдельный процесс""" + command = [PYTHON_EXEC, "-m", "treemapper"] + args + + result = subprocess.run(command, capture_output=True, text=True, cwd=cwd, encoding="utf-8", errors="replace") + return result + + +def test_cli_help_short(temp_project): + """Тест: вызов справки через -h""" + result = run_cli_command(["-h"], cwd=temp_project) + assert result.returncode == 0 + assert "usage: treemapper" in result.stdout.lower() + assert "--help" in result.stdout + assert "--output-file" in result.stdout + assert "--verbosity" in result.stdout + + +def test_cli_help_long(temp_project): + """Тест: вызов справки через --help""" + result = run_cli_command(["--help"], cwd=temp_project) + assert result.returncode == 0 + assert "usage: treemapper" in result.stdout.lower() + assert "--help" in result.stdout + + +def test_cli_invalid_verbosity(temp_project): + """Тест: неверный уровень verbosity""" + result = run_cli_command(["-v", "5"], cwd=temp_project) + assert result.returncode != 0 + + assert ( + "invalid choice: '5'" in result.stderr or "invalid choice: 5" in result.stderr + ), f"stderr does not contain expected invalid choice message for '5'. stderr: {result.stderr}" + + result_neg = run_cli_command(["-v", "-1"], cwd=temp_project) + assert result_neg.returncode != 0 + + assert ( + "invalid choice: '-1'" in result_neg.stderr or "invalid choice: -1" in result_neg.stderr + ), f"stderr does not contain expected invalid choice message for '-1'. stderr: {result_neg.stderr}" + + +def test_cli_version_display(temp_project): + """Тест: отображение версии (если будет добавлено)""" + pytest.skip("Version display option ('--version') not implemented yet.") diff --git a/tests/test_default_ignores.py b/tests/test_default_ignores.py new file mode 100644 index 00000000..db1870ad --- /dev/null +++ b/tests/test_default_ignores.py @@ -0,0 +1,108 @@ +# tests/test_default_ignores.py +import os # Required for permission operations +from pathlib import Path # Required for file path handling + +from .utils import get_all_files_in_tree, load_yaml + + +# Example function using the imports to avoid unused import warnings +def _ensure_dir_readable(directory: Path) -> bool: + """Check if a directory is readable.""" + return os.access(directory, os.R_OK) + + +def test_default_python_ignores(temp_project, run_mapper): + """Test for built-in Python-specific ignore patterns.""" + # Create Python cache files and directories + cache_dir = temp_project / "__pycache__" + cache_dir.mkdir(exist_ok=True) + (cache_dir / "module.cpython-310.pyc").touch() + + # Create .pyc files in the root + (temp_project / "module.pyc").touch() + (temp_project / "module.pyo").touch() + (temp_project / "module.pyd").touch() + + # Create .egg-info directory + egg_info_dir = temp_project / "package.egg-info" + egg_info_dir.mkdir(exist_ok=True) + (egg_info_dir / "PKG-INFO").touch() + + # Create pytest cache + pytest_cache_dir = temp_project / ".pytest_cache" + pytest_cache_dir.mkdir(exist_ok=True) + (pytest_cache_dir / "README.md").touch() + + # Create normal Python file that should be included + (temp_project / "actual_module.py").touch() + + # Run TreeMapper and check results + assert run_mapper(["."]) + result = load_yaml(temp_project / "directory_tree.yaml") + all_files = get_all_files_in_tree(result) + + # Check that Python cache files are ignored by default + assert "__pycache__" not in all_files + assert "module.cpython-310.pyc" not in all_files + assert "module.pyc" not in all_files + assert "module.pyo" not in all_files + assert "module.pyd" not in all_files + + # Egg info should be ignored + assert "package.egg-info" not in all_files + assert "PKG-INFO" not in all_files + + # Pytest cache should be ignored + assert ".pytest_cache" not in all_files + + # Regular Python files should be included + assert "actual_module.py" in all_files + + +def test_git_directory_ignored(temp_project, run_mapper): + """Test that .git directory is properly ignored.""" + # Create .git directory structure + git_dir = temp_project / ".git" + git_dir.mkdir(exist_ok=True) + (git_dir / "HEAD").touch() + (git_dir / "config").touch() + + # Create branches directory + branches_dir = git_dir / "branches" + branches_dir.mkdir(exist_ok=True) + (branches_dir / "main").touch() + + # Create a normal file that should be included + (temp_project / "README.md").touch() + + # Run TreeMapper and check results + assert run_mapper(["."]) + result = load_yaml(temp_project / "directory_tree.yaml") + all_files = get_all_files_in_tree(result) + + # Check that .git directory and its contents are ignored + assert ".git" not in all_files + assert "HEAD" not in all_files + assert "config" not in all_files + assert "branches" not in all_files + assert "main" not in all_files + + # Regular files should be included + assert "README.md" in all_files + + +def test_default_verbosity(temp_project, run_mapper, capfd): + """Test that default verbosity is ERROR level.""" + # Create some test files + (temp_project / "file1.txt").write_text("content1") + (temp_project / "file2.txt").write_text("content2") + + # Clear any buffer + capfd.readouterr() + + # Run with default verbosity (ERROR) + assert run_mapper(["."]) + out, err = capfd.readouterr() + + # At ERROR level, there should be no INFO messages + assert "INFO:" not in err diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py index 425235a5..93c751ca 100644 --- a/tests/test_edge_cases.py +++ b/tests/test_edge_cases.py @@ -1,67 +1,109 @@ -# tests/test_edge_cases.py - - -from .utils import get_all_files_in_tree, load_yaml - - -def test_empty_directory(temp_project, run_mapper): - """Тест: пустая директория в качестве входной.""" - empty_dir = temp_project / "empty_test_dir" - empty_dir.mkdir() - - output_path = temp_project / "empty_dir_output.yaml" - assert run_mapper([str(empty_dir), "-o", str(output_path)]) - result = load_yaml(output_path) - - assert result["name"] == empty_dir.name - assert result["type"] == "directory" - assert "children" not in result or not result["children"] - - -def test_directory_with_only_ignored(temp_project, run_mapper): - """Тест: директория содержит только игнорируемые файлы/папки.""" - ignored_dir = temp_project / "ignored_only_dir" - ignored_dir.mkdir() - (ignored_dir / ".DS_Store").touch() - (ignored_dir / "temp").mkdir() - (ignored_dir / "temp" / "file.tmp").touch() - (ignored_dir / ".gitignore").write_text(".DS_Store\ntemp/\n") - - output_path = temp_project / "ignored_only_output.yaml" - assert run_mapper([str(ignored_dir), "-o", str(output_path)]) - result = load_yaml(output_path) - - assert result["name"] == ignored_dir.name - assert result["type"] == "directory" - assert "children" in result and len(result["children"]) == 1 - assert result["children"][0]["name"] == ".gitignore" - - -def test_filenames_with_special_yaml_chars(temp_project, run_mapper): - """Тест: имена файлов со спецсимволами YAML (проверка ручного writer'а).""" - - (temp_project / "-startswithdash.txt").touch() - (temp_project / "quotes'single'.txt").touch() - (temp_project / "bracket[].txt").touch() - (temp_project / "curly{}.txt").touch() - (temp_project / "percent%.txt").touch() - (temp_project / "ampersand&.txt").touch() - - output_path = temp_project / "special_chars_output.yaml" - assert run_mapper([".", "-o", str(output_path)]) - - result = load_yaml(output_path) - all_files = get_all_files_in_tree(result) - - if (temp_project / "-startswithdash.txt").exists(): - assert "-startswithdash.txt" in all_files - if (temp_project / "quotes'single'.txt").exists(): - assert "quotes'single'.txt" in all_files - if (temp_project / "bracket[].txt").exists(): - assert "bracket[].txt" in all_files - if (temp_project / "curly{}.txt").exists(): - assert "curly{}.txt" in all_files - if (temp_project / "percent%.txt").exists(): - assert "percent%.txt" in all_files - if (temp_project / "ampersand&.txt").exists(): - assert "ampersand&.txt" in all_files +# tests/test_edge_cases.py + + +from .utils import get_all_files_in_tree, load_yaml + + +def test_empty_directory(temp_project, run_mapper): + """Тест: пустая директория в качестве входной.""" + empty_dir = temp_project / "empty_test_dir" + empty_dir.mkdir() + + output_path = temp_project / "empty_dir_output.yaml" + assert run_mapper([str(empty_dir), "-o", str(output_path)]) + result = load_yaml(output_path) + + assert result["name"] == empty_dir.name + assert result["type"] == "directory" + assert "children" not in result or not result["children"] + + +def test_directory_with_only_ignored(temp_project, run_mapper): + """Тест: директория содержит только игнорируемые файлы/папки.""" + ignored_dir = temp_project / "ignored_only_dir" + ignored_dir.mkdir() + (ignored_dir / ".DS_Store").touch() + (ignored_dir / "temp").mkdir() + (ignored_dir / "temp" / "file.tmp").touch() + (ignored_dir / ".gitignore").write_text(".DS_Store\ntemp/\n") + + output_path = temp_project / "ignored_only_output.yaml" + assert run_mapper([str(ignored_dir), "-o", str(output_path)]) + result = load_yaml(output_path) + + assert result["name"] == ignored_dir.name + assert result["type"] == "directory" + assert "children" in result and len(result["children"]) == 1 + assert result["children"][0]["name"] == ".gitignore" + + +def test_filenames_with_special_yaml_chars(temp_project, run_mapper): + """Тест: имена файлов со спецсимволами YAML (проверка ручного writer'а).""" + + # Basic special characters + (temp_project / "-startswithdash.txt").touch() + (temp_project / "quotes'single'.txt").touch() + (temp_project / "bracket[].txt").touch() + (temp_project / "curly{}.txt").touch() + (temp_project / "percent%.txt").touch() + (temp_project / "ampersand&.txt").touch() + + # YAML reserved words and values + (temp_project / "true").touch() + (temp_project / "false").touch() + (temp_project / "null").touch() + (temp_project / "yes").touch() + (temp_project / "no").touch() + (temp_project / "on").touch() + (temp_project / "off").touch() + + # Files with quotes that need escaping + try: + # Windows doesn't support double quotes in filenames + (temp_project / 'double"quote.txt').touch() + except OSError: + # Fall back to another special character on Windows + (temp_project / "special@char.txt").touch() + + # Numeric filenames + (temp_project / "123").touch() + (temp_project / "0.5").touch() + + output_path = temp_project / "special_chars_output.yaml" + assert run_mapper([".", "-o", str(output_path)]) + + result = load_yaml(output_path) + all_files = get_all_files_in_tree(result) + + # Check if files were correctly included in the output + if (temp_project / "-startswithdash.txt").exists(): + assert "-startswithdash.txt" in all_files + if (temp_project / "quotes'single'.txt").exists(): + assert "quotes'single'.txt" in all_files + if (temp_project / "bracket[].txt").exists(): + assert "bracket[].txt" in all_files + if (temp_project / "curly{}.txt").exists(): + assert "curly{}.txt" in all_files + if (temp_project / "percent%.txt").exists(): + assert "percent%.txt" in all_files + if (temp_project / "ampersand&.txt").exists(): + assert "ampersand&.txt" in all_files + + # Check YAML reserved words + assert "true" in all_files + assert "false" in all_files + assert "null" in all_files + assert "yes" in all_files + assert "no" in all_files + assert "on" in all_files + assert "off" in all_files + + # Check quoted/special filenames + if (temp_project / 'double"quote.txt').exists(): + assert 'double"quote.txt' in all_files + elif (temp_project / "special@char.txt").exists(): + assert "special@char.txt" in all_files + + # Check numeric filenames + assert "123" in all_files + assert "0.5" in all_files diff --git a/tests/test_errors.py b/tests/test_errors.py index 4768320e..37904961 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,93 +1,102 @@ -# tests/test_errors.py -import logging -import stat -import sys -from pathlib import Path - -import pytest - -from .utils import find_node_by_path, load_yaml - -# --- Тесты на некорректный ввод --- - - -def test_invalid_directory_path(run_mapper, capsys): - """Тест: указана несуществующая директория.""" - dir_name = "non_existent_directory" - assert not run_mapper([dir_name]) - captured = capsys.readouterr() - - assert "Error:" in captured.err - assert f"'{dir_name}'" in captured.err or f"{Path(dir_name).resolve()}" in captured.err - assert "does not exist" in captured.err or "not a valid directory" in captured.err - - -def test_input_path_is_file(run_mapper, temp_project, capsys): - """Тест: в качестве директории указан файл.""" - file_path = temp_project / "some_file.txt" - file_path.touch() - assert not run_mapper([str(file_path)]) - captured = capsys.readouterr() - - assert "Error:" in captured.err - assert str(file_path.resolve()) in captured.err - assert "not a valid directory" in captured.err - - -@pytest.mark.skipif(sys.platform == "win32", reason="os.chmod limited on Windows") -def test_unreadable_file(temp_project, run_mapper, set_perms, caplog): - """Тест: файл без прав на чтение.""" - unreadable_file = temp_project / "unreadable.txt" - unreadable_file.write_text("secret") - set_perms(unreadable_file, 0o000) - output_path = temp_project / "output_unreadable.yaml" - with caplog.at_level(logging.ERROR): - assert run_mapper([".", "-o", str(output_path)]) - assert output_path.exists(), f"Output file {output_path} was not created" - result = load_yaml(output_path) - file_node = find_node_by_path(result, ["unreadable.txt"]) - assert file_node is not None, "'unreadable.txt' node not found in generated YAML" - assert file_node.get("type") == "file" - assert file_node.get("content") == "\n" - assert any( - "Could not read" in record.message and "unreadable.txt" in record.message - for record in caplog.records - if record.levelno >= logging.ERROR - ), "Expected ERROR log message about reading failure not found" - - -@pytest.mark.skipif(sys.platform == "win32", reason="os.chmod limited on Windows") -def test_unwritable_output_dir(temp_project, run_mapper, set_perms, caplog): - """Тест: попытка записи в директорию без прав на запись.""" - unwritable_dir = temp_project / "locked_dir" - unwritable_dir.mkdir() - read_execute_perms = stat.S_IRUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH - set_perms(unwritable_dir, read_execute_perms) - output_path = unwritable_dir / "output.yaml" - with caplog.at_level(logging.ERROR): - run_mapper([".", "-o", str(output_path)]) - assert any( - "Unable to write to file" in record.message and str(output_path) in record.message - for record in caplog.records - if record.levelno >= logging.ERROR - ), f"Expected ERROR log message about writing failure to {output_path} not found" - assert not output_path.exists() - - -def test_output_path_is_directory(temp_project, run_mapper, caplog): - """Тест: путь вывода (-o) указывает на существующую директорию.""" - output_should_be_file = temp_project / "i_am_a_directory" - output_should_be_file.mkdir() - - with caplog.at_level(logging.ERROR): - - run_mapper([".", "-o", str(output_should_be_file)]) - - assert any( - "Unable to write to file" in rec.message and str(output_should_be_file) in rec.message - for rec in caplog.records - if rec.levelno >= logging.ERROR - ), f"Expected ERROR log message about writing failure to directory {output_should_be_file} not found" - - assert output_should_be_file.is_dir() - assert not list(output_should_be_file.iterdir()) +# tests/test_errors.py +import logging +import os +import stat +import sys +from pathlib import Path + +import pytest + +from .utils import find_node_by_path, load_yaml + +# --- Тесты на некорректный ввод --- + + +def test_invalid_directory_path(run_mapper, capsys): + """Тест: указана несуществующая директория.""" + dir_name = "non_existent_directory" + assert not run_mapper([dir_name]) + captured = capsys.readouterr() + + assert "Error:" in captured.err + assert f"'{dir_name}'" in captured.err or f"{Path(dir_name).resolve()}" in captured.err + assert "does not exist" in captured.err or "not a valid directory" in captured.err + + +def test_input_path_is_file(run_mapper, temp_project, capsys): + """Тест: в качестве директории указан файл.""" + file_path = temp_project / "some_file.txt" + file_path.touch() + assert not run_mapper([str(file_path)]) + captured = capsys.readouterr() + + assert "Error:" in captured.err + assert str(file_path.resolve()) in captured.err + assert "not a valid directory" in captured.err + + +@pytest.mark.skipif( + sys.platform == "win32" + or ("microsoft" in open("/proc/version", "r").read().lower() if os.path.exists("/proc/version") else False), + reason="os.chmod limited on Windows/WSL", +) +def test_unreadable_file(temp_project, run_mapper, set_perms, caplog): + """Тест: файл без прав на чтение.""" + unreadable_file = temp_project / "unreadable.txt" + unreadable_file.write_text("secret") + set_perms(unreadable_file, 0o000) + output_path = temp_project / "output_unreadable.yaml" + with caplog.at_level(logging.ERROR): + assert run_mapper([".", "-o", str(output_path)]) + assert output_path.exists(), f"Output file {output_path} was not created" + result = load_yaml(output_path) + file_node = find_node_by_path(result, ["unreadable.txt"]) + assert file_node is not None, "'unreadable.txt' node not found in generated YAML" + assert file_node.get("type") == "file" + assert file_node.get("content") == "\n" + assert any( + "Could not read" in record.message and "unreadable.txt" in record.message + for record in caplog.records + if record.levelno >= logging.ERROR + ), "Expected ERROR log message about reading failure not found" + + +@pytest.mark.skipif( + sys.platform == "win32" + or ("microsoft" in open("/proc/version", "r").read().lower() if os.path.exists("/proc/version") else False), + reason="os.chmod limited on Windows/WSL", +) +def test_unwritable_output_dir(temp_project, run_mapper, set_perms, caplog): + """Тест: попытка записи в директорию без прав на запись.""" + unwritable_dir = temp_project / "locked_dir" + unwritable_dir.mkdir() + read_execute_perms = stat.S_IRUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH + set_perms(unwritable_dir, read_execute_perms) + output_path = unwritable_dir / "output.yaml" + with caplog.at_level(logging.ERROR): + run_mapper([".", "-o", str(output_path)]) + assert any( + "Unable to write to file" in record.message and str(output_path) in record.message + for record in caplog.records + if record.levelno >= logging.ERROR + ), f"Expected ERROR log message about writing failure to {output_path} not found" + assert not output_path.exists() + + +def test_output_path_is_directory(temp_project, run_mapper, caplog): + """Тест: путь вывода (-o) указывает на существующую директорию.""" + output_should_be_file = temp_project / "i_am_a_directory" + output_should_be_file.mkdir() + + with caplog.at_level(logging.ERROR): + + run_mapper([".", "-o", str(output_should_be_file)]) + + assert any( + "Unable to write to file" in rec.message and str(output_should_be_file) in rec.message + for rec in caplog.records + if rec.levelno >= logging.ERROR + ), f"Expected ERROR log message about writing failure to directory {output_should_be_file} not found" + + assert output_should_be_file.is_dir() + assert not list(output_should_be_file.iterdir()) diff --git a/tests/test_ignore.py b/tests/test_ignore.py index 95f4b5e2..4d45ccb6 100644 --- a/tests/test_ignore.py +++ b/tests/test_ignore.py @@ -1,268 +1,273 @@ -# tests/test_ignore.py -import logging -import sys - -import pytest - -from .utils import get_all_files_in_tree, load_yaml - - -def test_custom_ignore(temp_project, run_mapper): - """Test custom ignore patterns.""" - ignore_file = temp_project / "custom.ignore" - ignore_file.write_text( - """ -# Ignore all Python files -*.py -# Ignore docs directory -docs/ -# Ignore specific file -.gitignore -""" - ) - assert run_mapper([".", "-i", str(ignore_file)]) - result = load_yaml(temp_project / "directory_tree.yaml") - all_files = get_all_files_in_tree(result) - assert not any(isinstance(f, str) and f.endswith(".py") for f in all_files) - assert "docs" not in all_files - assert ".gitignore" not in all_files - - -def test_gitignore_patterns(temp_project, run_mapper): - """Test .gitignore pattern handling.""" - (temp_project / ".gitignore").write_text("*.pyc\n__pycache__/\n") - (temp_project / "src" / ".gitignore").write_text("local_only.py\n") - (temp_project / "test.pyc").touch() - (temp_project / "__pycache__").mkdir() - (temp_project / "__pycache__" / "cachefile").touch() - (temp_project / "src" / "local_only.py").touch() - (temp_project / "src" / "allowed.py").touch() - assert run_mapper(["."]) - result = load_yaml(temp_project / "directory_tree.yaml") - all_files = get_all_files_in_tree(result) - assert "test.pyc" not in all_files - assert "__pycache__" not in all_files - assert "cachefile" not in all_files - assert "local_only.py" not in all_files - assert "allowed.py" in all_files - - -def test_symlinks_and_special_files(temp_project, run_mapper): - """Test ignore patterns with symlinks and special files.""" - hidden_dir = temp_project / ".hidden_dir" - hidden_file = temp_project / ".hidden_file" - target_file = temp_project / "target.txt" - symlink_file = temp_project / "link.txt" - - hidden_dir.mkdir() - hidden_file.touch() - target_file.write_text("target content") - - can_symlink = True - if sys.platform == "win32": - try: - symlink_file.symlink_to(target_file, target_is_directory=False) - except OSError: - logging.warning("Could not create symlink on Windows, skipping symlink part.") - can_symlink = False - except AttributeError: - logging.warning("Path.symlink_to not available, skipping symlink part.") - can_symlink = False - else: - try: - symlink_file.symlink_to(target_file) - except OSError as e: - logging.warning(f"Could not create symlink: {e}, skipping symlink part.") - can_symlink = False - - (temp_project / ".treemapperignore").write_text(".*\n!.gitignore\n") - assert run_mapper(["."]) - result = load_yaml(temp_project / "directory_tree.yaml") - all_files = get_all_files_in_tree(result) - assert ".hidden_dir" not in all_files - assert ".hidden_file" not in all_files - assert ".gitignore" in all_files - assert "target.txt" in all_files - if can_symlink: - assert symlink_file.name not in all_files - - -def test_empty_and_invalid_ignores(temp_project, run_mapper): - """Test handling of empty and invalid ignore files.""" - (temp_project / ".gitignore").write_text("") - (temp_project / ".treemapperignore").write_text("\n\n# Just comments\n\n") - (temp_project / "empty.ignore").write_text("") - (temp_project / "invalid.ignore").write_text("[\ninvalid\npattern\n") - assert run_mapper([".", "-o", "out_empty.yaml"]) - assert run_mapper( - [ - ".", - "-i", - str(temp_project / "empty.ignore"), - "-o", - "out_custom_empty.yaml", - ] - ) - assert run_mapper( - [ - ".", - "-i", - str(temp_project / "invalid.ignore"), - "-o", - "out_invalid.yaml", - ] - ) - assert run_mapper([".", "-i", "nonexistent.ignore", "-o", "out_nonexistent.yaml"]) - assert (temp_project / "out_nonexistent.yaml").exists() - assert load_yaml(temp_project / "out_empty.yaml") is not None - - -def test_ignore_negation(temp_project, run_mapper): - """Тест: отмена игнорирования с помощью '!'.""" - (temp_project / ".gitignore").write_text("*.log\n!important.log\n") - (temp_project / "app.log").touch() - (temp_project / "important.log").touch() - (temp_project / "another.log").touch() - output_path = temp_project / "negation_output.yaml" - assert run_mapper([".", "-o", str(output_path)]) - result = load_yaml(output_path) - all_files = get_all_files_in_tree(result) - assert "app.log" not in all_files - assert "another.log" not in all_files - assert "important.log" in all_files, "Negated file 'important.log' was incorrectly ignored" - - -def test_ignore_double_star(temp_project, run_mapper, caplog): - """Тест: игнорирование с помощью '**'.""" - - caplog.set_level(logging.DEBUG) - (temp_project / ".gitignore").write_text("**/temp_files/\n") - (temp_project / "a" / "temp_files").mkdir(parents=True) - (temp_project / "a" / "temp_files" / "file1.tmp").touch() - (temp_project / "b" / "c" / "temp_files").mkdir(parents=True) - (temp_project / "b" / "c" / "temp_files" / "file2.tmp").touch() - (temp_project / "a" / "other_dir").mkdir() - (temp_project / "a" / "other_dir" / "file3.txt").touch() - output_path = temp_project / "doublestar_output.yaml" - assert run_mapper([".", "-o", str(output_path)]) - result = load_yaml(output_path) - all_names = get_all_files_in_tree(result) - - assert "temp_files" not in all_names, "'temp_files' dir should be ignored by **/temp_files/" - assert "file1.tmp" not in all_names, "'file1.tmp' should be ignored (inside ignored dir)" - assert "file2.tmp" not in all_names, "'file2.tmp' should be ignored (inside ignored dir)" - assert "other_dir" in all_names, "'other_dir' should NOT be ignored" - assert "file3.txt" in all_names, "'file3.txt' should NOT be ignored" - - -def test_ignore_output_file_itself(temp_project, run_mapper): - """Тест: игнорирование самого файла вывода в разных местах.""" - output_path1 = temp_project / "output1.yaml" - assert run_mapper([".", "-o", str(output_path1)]) - result1 = load_yaml(output_path1) - all_files1 = get_all_files_in_tree(result1) - assert output_path1.name not in all_files1, f"Output file {output_path1.name} was not ignored in root" - - output_path2 = temp_project / "src" / "output2.yaml" - assert run_mapper([".", "-o", str(output_path2)]) - result2 = load_yaml(output_path2) - all_files2 = get_all_files_in_tree(result2) - assert output_path2.name not in all_files2, f"Output file {output_path2.name} was not ignored in existing subdir" - - output_dir3 = temp_project / "output_dir" - output_path3 = output_dir3 / "output3.yaml" - - output_dir3.mkdir(parents=True, exist_ok=True) - assert run_mapper([".", "-o", str(output_path3)]) - assert output_path3.exists() - result3 = load_yaml(output_path3) - all_files3 = get_all_files_in_tree(result3) - assert output_dir3.name in all_files3, f"Output directory {output_dir3.name} should be listed" - assert output_path3.name not in all_files3, f"Output file {output_path3.name} was not ignored when in a new subdir" - - -def test_no_default_ignores_flag(temp_project, run_mapper): - """Тест: флаг --no-default-ignores.""" - git_dir = temp_project / ".git" - git_dir.mkdir(parents=True, exist_ok=True) - (git_dir / "config").write_text("test") - (temp_project / "ignored_by_git.pyc").touch() - (temp_project / ".gitignore").write_text("*.pyc\n") - - treemapper_ignore = temp_project / ".tmignore_fortest" - treemapper_ignore.write_text("treemapper_ignored/\n") - (temp_project / "treemapper_ignored").mkdir() - (temp_project / "treemapper_ignored" / "file.txt").touch() - output_path = temp_project / "output_no_defaults.yaml" - - custom_ignore = temp_project / "custom_empty.ignore" - custom_ignore.touch() - assert run_mapper( - [ - ".", - "--no-default-ignores", - "-i", - str(custom_ignore), - "-o", - str(output_path), - ] - ) - result = load_yaml(output_path) - all_files = get_all_files_in_tree(result) - assert ".git" in all_files, ".git directory should NOT be ignored with --no-default-ignores" - assert "config" in all_files, "File inside .git should NOT be ignored with --no-default-ignores" - assert "ignored_by_git.pyc" in all_files, "*.pyc from .gitignore should NOT be ignored with --no-default-ignores" - assert "treemapper_ignored" in all_files, "'treemapper_ignored/' should NOT be ignored with --no-default-ignores" - assert "file.txt" in all_files - assert ".gitignore" in all_files - assert ".treemapperignore" in all_files - assert treemapper_ignore.name in all_files - assert custom_ignore.name in all_files - assert ( - output_path.name not in all_files - ), f"Output file {output_path.name} should still be ignored even with --no-default-ignores" - - -@pytest.mark.skipif(sys.platform == "win32", reason="os.chmod limited on Windows") -def test_unreadable_ignore_file(temp_project, run_mapper, set_perms, caplog): - """Тест: файл .treemapperignore не имеет прав на чтение.""" - ignore_file = temp_project / ".treemapperignore" - ignore_file.write_text(".git/\n") - set_perms(ignore_file, 0o000) - - with caplog.at_level(logging.WARNING): - assert run_mapper(["."]) - - assert any( - "Could not read ignore file" in rec.message and ignore_file.name in rec.message - for rec in caplog.records - if rec.levelno >= logging.WARNING - ), "Expected WARNING log about unreadable ignore file not found" - - -def test_bad_encoding_ignore_file(temp_project, run_mapper, caplog): - """Тест: файл .treemapperignore имеет не-UTF8 кодировку.""" - ignore_file = temp_project / ".treemapperignore" - try: - - ignore_file.write_text(".git/\nпапка_игнор/\n", encoding="cp1251") - except LookupError: - pytest.skip("CP1251 codec not found") - - with caplog.at_level(logging.WARNING): - assert run_mapper(["."]) - - assert any( - "Could not decode ignore file" in rec.message and ignore_file.name in rec.message and "UTF-8" in rec.message - for rec in caplog.records - if rec.levelno >= logging.WARNING - ), "Expected WARNING log about undecodable ignore file not found" - - result = load_yaml(temp_project / "directory_tree.yaml") - - (temp_project / "папка_игнор").mkdir() - - assert run_mapper(["."]) - result = load_yaml(temp_project / "directory_tree.yaml") - all_files = get_all_files_in_tree(result) - assert "папка_игнор" in all_files +# tests/test_ignore.py +import logging +import os +import sys + +import pytest + +from .utils import get_all_files_in_tree, load_yaml + + +def test_custom_ignore(temp_project, run_mapper): + """Test custom ignore patterns.""" + ignore_file = temp_project / "custom.ignore" + ignore_file.write_text( + """ +# Ignore all Python files +*.py +# Ignore docs directory +docs/ +# Ignore specific file +.gitignore +""" + ) + assert run_mapper([".", "-i", str(ignore_file)]) + result = load_yaml(temp_project / "directory_tree.yaml") + all_files = get_all_files_in_tree(result) + assert not any(isinstance(f, str) and f.endswith(".py") for f in all_files) + assert "docs" not in all_files + assert ".gitignore" not in all_files + + +def test_gitignore_patterns(temp_project, run_mapper): + """Test .gitignore pattern handling.""" + (temp_project / ".gitignore").write_text("*.pyc\n__pycache__/\n") + (temp_project / "src" / ".gitignore").write_text("local_only.py\n") + (temp_project / "test.pyc").touch() + (temp_project / "__pycache__").mkdir() + (temp_project / "__pycache__" / "cachefile").touch() + (temp_project / "src" / "local_only.py").touch() + (temp_project / "src" / "allowed.py").touch() + assert run_mapper(["."]) + result = load_yaml(temp_project / "directory_tree.yaml") + all_files = get_all_files_in_tree(result) + assert "test.pyc" not in all_files + assert "__pycache__" not in all_files + assert "cachefile" not in all_files + assert "local_only.py" not in all_files + assert "allowed.py" in all_files + + +def test_symlinks_and_special_files(temp_project, run_mapper): + """Test ignore patterns with symlinks and special files.""" + hidden_dir = temp_project / ".hidden_dir" + hidden_file = temp_project / ".hidden_file" + target_file = temp_project / "target.txt" + symlink_file = temp_project / "link.txt" + + hidden_dir.mkdir() + hidden_file.touch() + target_file.write_text("target content") + + can_symlink = True + if sys.platform == "win32": + try: + symlink_file.symlink_to(target_file, target_is_directory=False) + except OSError: + logging.warning("Could not create symlink on Windows, skipping symlink part.") + can_symlink = False + except AttributeError: + logging.warning("Path.symlink_to not available, skipping symlink part.") + can_symlink = False + else: + try: + symlink_file.symlink_to(target_file) + except OSError as e: + logging.warning(f"Could not create symlink: {e}, skipping symlink part.") + can_symlink = False + + (temp_project / ".treemapperignore").write_text(".*\n!.gitignore\n") + assert run_mapper(["."]) + result = load_yaml(temp_project / "directory_tree.yaml") + all_files = get_all_files_in_tree(result) + assert ".hidden_dir" not in all_files + assert ".hidden_file" not in all_files + assert ".gitignore" in all_files + assert "target.txt" in all_files + if can_symlink: + assert symlink_file.name not in all_files + + +def test_empty_and_invalid_ignores(temp_project, run_mapper): + """Test handling of empty and invalid ignore files.""" + (temp_project / ".gitignore").write_text("") + (temp_project / ".treemapperignore").write_text("\n\n# Just comments\n\n") + (temp_project / "empty.ignore").write_text("") + (temp_project / "invalid.ignore").write_text("[\ninvalid\npattern\n") + assert run_mapper([".", "-o", "out_empty.yaml"]) + assert run_mapper( + [ + ".", + "-i", + str(temp_project / "empty.ignore"), + "-o", + "out_custom_empty.yaml", + ] + ) + assert run_mapper( + [ + ".", + "-i", + str(temp_project / "invalid.ignore"), + "-o", + "out_invalid.yaml", + ] + ) + assert run_mapper([".", "-i", "nonexistent.ignore", "-o", "out_nonexistent.yaml"]) + assert (temp_project / "out_nonexistent.yaml").exists() + assert load_yaml(temp_project / "out_empty.yaml") is not None + + +def test_ignore_negation(temp_project, run_mapper): + """Тест: отмена игнорирования с помощью '!'.""" + (temp_project / ".gitignore").write_text("*.log\n!important.log\n") + (temp_project / "app.log").touch() + (temp_project / "important.log").touch() + (temp_project / "another.log").touch() + output_path = temp_project / "negation_output.yaml" + assert run_mapper([".", "-o", str(output_path)]) + result = load_yaml(output_path) + all_files = get_all_files_in_tree(result) + assert "app.log" not in all_files + assert "another.log" not in all_files + assert "important.log" in all_files, "Negated file 'important.log' was incorrectly ignored" + + +def test_ignore_double_star(temp_project, run_mapper, caplog): + """Тест: игнорирование с помощью '**'.""" + + caplog.set_level(logging.DEBUG) + (temp_project / ".gitignore").write_text("**/temp_files/\n") + (temp_project / "a" / "temp_files").mkdir(parents=True) + (temp_project / "a" / "temp_files" / "file1.tmp").touch() + (temp_project / "b" / "c" / "temp_files").mkdir(parents=True) + (temp_project / "b" / "c" / "temp_files" / "file2.tmp").touch() + (temp_project / "a" / "other_dir").mkdir() + (temp_project / "a" / "other_dir" / "file3.txt").touch() + output_path = temp_project / "doublestar_output.yaml" + assert run_mapper([".", "-o", str(output_path)]) + result = load_yaml(output_path) + all_names = get_all_files_in_tree(result) + + assert "temp_files" not in all_names, "'temp_files' dir should be ignored by **/temp_files/" + assert "file1.tmp" not in all_names, "'file1.tmp' should be ignored (inside ignored dir)" + assert "file2.tmp" not in all_names, "'file2.tmp' should be ignored (inside ignored dir)" + assert "other_dir" in all_names, "'other_dir' should NOT be ignored" + assert "file3.txt" in all_names, "'file3.txt' should NOT be ignored" + + +def test_ignore_output_file_itself(temp_project, run_mapper): + """Тест: игнорирование самого файла вывода в разных местах.""" + output_path1 = temp_project / "output1.yaml" + assert run_mapper([".", "-o", str(output_path1)]) + result1 = load_yaml(output_path1) + all_files1 = get_all_files_in_tree(result1) + assert output_path1.name not in all_files1, f"Output file {output_path1.name} was not ignored in root" + + output_path2 = temp_project / "src" / "output2.yaml" + assert run_mapper([".", "-o", str(output_path2)]) + result2 = load_yaml(output_path2) + all_files2 = get_all_files_in_tree(result2) + assert output_path2.name not in all_files2, f"Output file {output_path2.name} was not ignored in existing subdir" + + output_dir3 = temp_project / "output_dir" + output_path3 = output_dir3 / "output3.yaml" + + output_dir3.mkdir(parents=True, exist_ok=True) + assert run_mapper([".", "-o", str(output_path3)]) + assert output_path3.exists() + result3 = load_yaml(output_path3) + all_files3 = get_all_files_in_tree(result3) + assert output_dir3.name in all_files3, f"Output directory {output_dir3.name} should be listed" + assert output_path3.name not in all_files3, f"Output file {output_path3.name} was not ignored when in a new subdir" + + +def test_no_default_ignores_flag(temp_project, run_mapper): + """Тест: флаг --no-default-ignores.""" + git_dir = temp_project / ".git" + git_dir.mkdir(parents=True, exist_ok=True) + (git_dir / "config").write_text("test") + (temp_project / "ignored_by_git.pyc").touch() + (temp_project / ".gitignore").write_text("*.pyc\n") + + treemapper_ignore = temp_project / ".tmignore_fortest" + treemapper_ignore.write_text("treemapper_ignored/\n") + (temp_project / "treemapper_ignored").mkdir() + (temp_project / "treemapper_ignored" / "file.txt").touch() + output_path = temp_project / "output_no_defaults.yaml" + + custom_ignore = temp_project / "custom_empty.ignore" + custom_ignore.touch() + assert run_mapper( + [ + ".", + "--no-default-ignores", + "-i", + str(custom_ignore), + "-o", + str(output_path), + ] + ) + result = load_yaml(output_path) + all_files = get_all_files_in_tree(result) + assert ".git" in all_files, ".git directory should NOT be ignored with --no-default-ignores" + assert "config" in all_files, "File inside .git should NOT be ignored with --no-default-ignores" + assert "ignored_by_git.pyc" in all_files, "*.pyc from .gitignore should NOT be ignored with --no-default-ignores" + assert "treemapper_ignored" in all_files, "'treemapper_ignored/' should NOT be ignored with --no-default-ignores" + assert "file.txt" in all_files + assert ".gitignore" in all_files + assert ".treemapperignore" in all_files + assert treemapper_ignore.name in all_files + assert custom_ignore.name in all_files + assert ( + output_path.name not in all_files + ), f"Output file {output_path.name} should still be ignored even with --no-default-ignores" + + +@pytest.mark.skipif( + sys.platform == "win32" + or ("microsoft" in open("/proc/version", "r").read().lower() if os.path.exists("/proc/version") else False), + reason="os.chmod limited on Windows/WSL", +) +def test_unreadable_ignore_file(temp_project, run_mapper, set_perms, caplog): + """Тест: файл .treemapperignore не имеет прав на чтение.""" + ignore_file = temp_project / ".treemapperignore" + ignore_file.write_text(".git/\n") + set_perms(ignore_file, 0o000) + + with caplog.at_level(logging.WARNING): + assert run_mapper(["."]) + + assert any( + "Could not read ignore file" in rec.message and ignore_file.name in rec.message + for rec in caplog.records + if rec.levelno >= logging.WARNING + ), "Expected WARNING log about unreadable ignore file not found" + + +def test_bad_encoding_ignore_file(temp_project, run_mapper, caplog): + """Тест: файл .treemapperignore имеет не-UTF8 кодировку.""" + ignore_file = temp_project / ".treemapperignore" + try: + + ignore_file.write_text(".git/\nпапка_игнор/\n", encoding="cp1251") + except LookupError: + pytest.skip("CP1251 codec not found") + + with caplog.at_level(logging.WARNING): + assert run_mapper(["."]) + + assert any( + "Could not decode ignore file" in rec.message and ignore_file.name in rec.message and "UTF-8" in rec.message + for rec in caplog.records + if rec.levelno >= logging.WARNING + ), "Expected WARNING log about undecodable ignore file not found" + + result = load_yaml(temp_project / "directory_tree.yaml") + + (temp_project / "папка_игнор").mkdir() + + assert run_mapper(["."]) + result = load_yaml(temp_project / "directory_tree.yaml") + all_files = get_all_files_in_tree(result) + assert "папка_игнор" in all_files diff --git a/tests/test_ignore_advanced.py b/tests/test_ignore_advanced.py index a846476c..0e7cea21 100644 --- a/tests/test_ignore_advanced.py +++ b/tests/test_ignore_advanced.py @@ -1,101 +1,101 @@ -# tests/test_ignore_advanced.py -import logging - -import pytest - -from .utils import find_node_by_path, get_all_files_in_tree, load_yaml - - -def test_ignore_precedence_subdir_over_root(temp_project, run_mapper): - """Тест: правило в .gitignore подпапки НЕ отменяет правило из корня (текущее поведение)""" - (temp_project / ".gitignore").write_text("*.txt\n") - (temp_project / "subdir").mkdir() - (temp_project / "subdir" / ".gitignore").write_text("!allow.txt\n") - (temp_project / "root.txt").touch() - (temp_project / "subdir" / "ignore.txt").touch() - (temp_project / "subdir" / "allow.txt").touch() - - assert run_mapper(["."]) - result = load_yaml(temp_project / "directory_tree.yaml") - all_files = get_all_files_in_tree(result) - - assert "root.txt" not in all_files - assert "ignore.txt" not in all_files - assert "allow.txt" not in all_files, "'allow.txt' should BE ignored due to root rule (current logic limitation)" - - -def test_ignore_precedence_treemapper_over_git(temp_project, run_mapper): - """Тест: правила .treemapperignore и .gitignore суммируются""" - (temp_project / ".gitignore").write_text("*.pyc\n") - (temp_project / ".treemapperignore").write_text("*.log\n.git/\n") - (temp_project / "file.pyc").touch() - (temp_project / "file.log").touch() - (temp_project / "file.txt").touch() - - assert run_mapper(["."]) - result = load_yaml(temp_project / "directory_tree.yaml") - all_files = get_all_files_in_tree(result) - - assert "file.pyc" not in all_files - assert "file.log" not in all_files - assert "file.txt" in all_files - - -def test_ignore_precedence_custom_over_defaults(temp_project, run_mapper): - """Тест: кастомный файл (-i) ДОПОЛНЯЕТ стандартные игноры""" - (temp_project / ".gitignore").write_text("*.pyc\n") - (temp_project / ".treemapperignore").write_text("*.log\n.git/\n") - custom_ignore = temp_project / "custom.ignore" - custom_ignore.write_text("*.tmp\n") - - (temp_project / "file.pyc").touch() - (temp_project / "file.log").touch() - (temp_project / "file.tmp").touch() - (temp_project / "file.txt").touch() - - assert run_mapper([".", "-i", str(custom_ignore)]) - result = load_yaml(temp_project / "directory_tree.yaml") - all_files = get_all_files_in_tree(result) - - assert "file.pyc" not in all_files - assert "file.log" not in all_files - assert "file.tmp" not in all_files - assert "file.txt" in all_files - - -def test_ignore_interaction_combined_vs_git(temp_project, run_mapper): - """Тест: Файл игнорируется combined_spec, но раз-игнорирован в .gitignore""" - output_path = temp_project / "output.yaml" - (temp_project / ".gitignore").write_text("!output.yaml\n") - - assert run_mapper([".", "-o", str(output_path)]) - result = load_yaml(output_path) - all_files = get_all_files_in_tree(result) - - assert output_path.name not in all_files, "Output file should be ignored even if negated in .gitignore" - - -# ---> ИЗМЕНЕНИЕ: Добавлен маркер xfail и ассерт проверяет текущее (неверное) поведение <--- -@pytest.mark.xfail( - reason="Anchored gitignore pattern ('/file') seems ignored by logs but file appears in output on Windows.", - strict=False, -) -def test_ignore_patterns_anchored(temp_project, run_mapper, caplog): - """Тест: паттерны, привязанные к корню ('/'), и не привязанные""" - caplog.set_level(logging.DEBUG) - (temp_project / ".gitignore").write_text("/root_ignore.txt\nsubdir_ignore.txt") - (temp_project / "root_ignore.txt").touch() - (temp_project / "subdir").mkdir() - (temp_project / "subdir" / "root_ignore.txt").touch() - (temp_project / "subdir" / "subdir_ignore.txt").touch() - (temp_project / "subdir_ignore.txt").touch() - - assert run_mapper([".", "-o", "anchored_output.yaml"]) - result = load_yaml(temp_project / "anchored_output.yaml") - all_files = get_all_files_in_tree(result) - - assert "root_ignore.txt" in all_files, "EXPECTING FAILURE: Root file IS NOT ignored by anchored pattern '/root_ignore.txt'" - - assert find_node_by_path(result, ["subdir", "root_ignore.txt"]) is not None, "/root_ignore.txt should NOT ignore subdir file" - assert find_node_by_path(result, ["subdir", "subdir_ignore.txt"]) is None, "subdir_ignore.txt should ignore file in subdir" - assert find_node_by_path(result, ["subdir_ignore.txt"]) is None, "subdir_ignore.txt should ignore file in root" +# tests/test_ignore_advanced.py +import logging + +import pytest + +from .utils import find_node_by_path, get_all_files_in_tree, load_yaml + + +def test_ignore_precedence_subdir_over_root(temp_project, run_mapper): + """Тест: правило в .gitignore подпапки НЕ отменяет правило из корня (текущее поведение)""" + (temp_project / ".gitignore").write_text("*.txt\n") + (temp_project / "subdir").mkdir() + (temp_project / "subdir" / ".gitignore").write_text("!allow.txt\n") + (temp_project / "root.txt").touch() + (temp_project / "subdir" / "ignore.txt").touch() + (temp_project / "subdir" / "allow.txt").touch() + + assert run_mapper(["."]) + result = load_yaml(temp_project / "directory_tree.yaml") + all_files = get_all_files_in_tree(result) + + assert "root.txt" not in all_files + assert "ignore.txt" not in all_files + assert "allow.txt" not in all_files, "'allow.txt' should BE ignored due to root rule (current logic limitation)" + + +def test_ignore_precedence_treemapper_over_git(temp_project, run_mapper): + """Тест: правила .treemapperignore и .gitignore суммируются""" + (temp_project / ".gitignore").write_text("*.pyc\n") + (temp_project / ".treemapperignore").write_text("*.log\n.git/\n") + (temp_project / "file.pyc").touch() + (temp_project / "file.log").touch() + (temp_project / "file.txt").touch() + + assert run_mapper(["."]) + result = load_yaml(temp_project / "directory_tree.yaml") + all_files = get_all_files_in_tree(result) + + assert "file.pyc" not in all_files + assert "file.log" not in all_files + assert "file.txt" in all_files + + +def test_ignore_precedence_custom_over_defaults(temp_project, run_mapper): + """Тест: кастомный файл (-i) ДОПОЛНЯЕТ стандартные игноры""" + (temp_project / ".gitignore").write_text("*.pyc\n") + (temp_project / ".treemapperignore").write_text("*.log\n.git/\n") + custom_ignore = temp_project / "custom.ignore" + custom_ignore.write_text("*.tmp\n") + + (temp_project / "file.pyc").touch() + (temp_project / "file.log").touch() + (temp_project / "file.tmp").touch() + (temp_project / "file.txt").touch() + + assert run_mapper([".", "-i", str(custom_ignore)]) + result = load_yaml(temp_project / "directory_tree.yaml") + all_files = get_all_files_in_tree(result) + + assert "file.pyc" not in all_files + assert "file.log" not in all_files + assert "file.tmp" not in all_files + assert "file.txt" in all_files + + +def test_ignore_interaction_combined_vs_git(temp_project, run_mapper): + """Тест: Файл игнорируется combined_spec, но раз-игнорирован в .gitignore""" + output_path = temp_project / "output.yaml" + (temp_project / ".gitignore").write_text("!output.yaml\n") + + assert run_mapper([".", "-o", str(output_path)]) + result = load_yaml(output_path) + all_files = get_all_files_in_tree(result) + + assert output_path.name not in all_files, "Output file should be ignored even if negated in .gitignore" + + +# ---> ИЗМЕНЕНИЕ: Добавлен маркер xfail и ассерт проверяет текущее (неверное) поведение <--- +@pytest.mark.xfail( + reason="Anchored gitignore pattern ('/file') seems ignored by logs but file appears in output on Windows.", + strict=False, +) +def test_ignore_patterns_anchored(temp_project, run_mapper, caplog): + """Тест: паттерны, привязанные к корню ('/'), и не привязанные""" + caplog.set_level(logging.DEBUG) + (temp_project / ".gitignore").write_text("/root_ignore.txt\nsubdir_ignore.txt") + (temp_project / "root_ignore.txt").touch() + (temp_project / "subdir").mkdir() + (temp_project / "subdir" / "root_ignore.txt").touch() + (temp_project / "subdir" / "subdir_ignore.txt").touch() + (temp_project / "subdir_ignore.txt").touch() + + assert run_mapper([".", "-o", "anchored_output.yaml"]) + result = load_yaml(temp_project / "anchored_output.yaml") + all_files = get_all_files_in_tree(result) + + assert "root_ignore.txt" in all_files, "EXPECTING FAILURE: Root file IS NOT ignored by anchored pattern '/root_ignore.txt'" + + assert find_node_by_path(result, ["subdir", "root_ignore.txt"]) is not None, "/root_ignore.txt should NOT ignore subdir file" + assert find_node_by_path(result, ["subdir", "subdir_ignore.txt"]) is None, "subdir_ignore.txt should ignore file in subdir" + assert find_node_by_path(result, ["subdir_ignore.txt"]) is None, "subdir_ignore.txt should ignore file in root" diff --git a/tests/test_logging.py b/tests/test_logging.py index 2a8718cf..515976c8 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -1,38 +1,38 @@ -# src/treemapper/logger.py -import logging -import sys - -# Убедимся, что имя логгера совпадает с именем пакета (хорошая практика) -# Но для простоты пока конфигурируем корневой логгер -# logger = logging.getLogger('treemapper') - - -def setup_logging(verbosity: int) -> None: - """Configure the root logger based on verbosity.""" - level_map = { - 0: logging.ERROR, - 1: logging.WARNING, - 2: logging.INFO, - 3: logging.DEBUG, - } - - level = level_map.get(verbosity, logging.INFO) - - root_logger = logging.getLogger() - - root_logger.setLevel(level) - - for handler in root_logger.handlers[:]: - root_logger.removeHandler(handler) - - handler = logging.StreamHandler(sys.stderr) - - handler.setLevel(level) - - formatter = logging.Formatter("%(levelname)s: %(message)s") - - handler.setFormatter(formatter) - - root_logger.addHandler(handler) - - logging.debug(f"Logging setup complete for root logger with level {level} ({logging.getLevelName(level)})") +# src/treemapper/logger.py +import logging +import sys + +# Убедимся, что имя логгера совпадает с именем пакета (хорошая практика) +# Но для простоты пока конфигурируем корневой логгер +# logger = logging.getLogger('treemapper') + + +def setup_logging(verbosity: int) -> None: + """Configure the root logger based on verbosity.""" + level_map = { + 0: logging.ERROR, + 1: logging.WARNING, + 2: logging.INFO, + 3: logging.DEBUG, + } + + level = level_map.get(verbosity, logging.INFO) + + root_logger = logging.getLogger() + + root_logger.setLevel(level) + + for handler in root_logger.handlers[:]: + root_logger.removeHandler(handler) + + handler = logging.StreamHandler(sys.stderr) + + handler.setLevel(level) + + formatter = logging.Formatter("%(levelname)s: %(message)s") + + handler.setFormatter(formatter) + + root_logger.addHandler(handler) + + logging.debug(f"Logging setup complete for root logger with level {level} ({logging.getLevelName(level)})") diff --git a/tests/utils.py b/tests/utils.py index c9c0ec03..d13f2b3a 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,63 +1,63 @@ -# tests/utils.py -from pathlib import Path -from typing import Any, Dict, Hashable, List, Optional, Set - -import pytest -import yaml - - -def load_yaml(path: Path) -> Dict[str, Any]: - """Load YAML file and return its contents.""" - try: - with path.open("r", encoding="utf-8") as f: - return yaml.load(f, Loader=yaml.SafeLoader) - except FileNotFoundError: - pytest.fail(f"Output YAML file not found: {path}") - except Exception as e: - pytest.fail(f"Failed to load or parse YAML file {path}: {e}") - - -def get_all_files_in_tree(node: Dict[str, Any]) -> Set[str]: - """Recursively get all file and directory names from the loaded tree structure.""" - - names: Set[str] = set() - if not isinstance(node, dict) or "name" not in node: - return names - names.add(node["name"]) - if "children" in node and isinstance(node["children"], list): - for child in node["children"]: - if isinstance(child, dict): - names.update(get_all_files_in_tree(child)) - return names - - -def find_node_by_path(tree: Dict[str, Any], path_segments: List[str]) -> Optional[Dict[str, Any]]: - """Find a node in the tree by list of path segments relative to root node.""" - current_node = tree - for segment in path_segments: - if current_node is None or "children" not in current_node or not isinstance(current_node["children"], list): - return None - found_child = None - for child in current_node["children"]: - if isinstance(child, dict) and child.get("name") == segment: - found_child = child - break - if found_child is None: - return None - current_node = found_child - return current_node - - -def make_hashable(obj: Any) -> Hashable: - """Recursively convert dicts and lists to hashable tuples.""" - if isinstance(obj, dict): - return tuple(sorted((k, make_hashable(v)) for k, v in obj.items())) - if isinstance(obj, list): - return tuple(make_hashable(item) for item in obj) - if isinstance(obj, (str, int, float, bool, type(None))): - return obj - try: - hash(obj) - return obj - except TypeError: - return repr(obj) +# tests/utils.py +from pathlib import Path +from typing import Any, Dict, Hashable, List, Optional, Set + +import pytest +import yaml + + +def load_yaml(path: Path) -> Dict[str, Any]: + """Load YAML file and return its contents.""" + try: + with path.open("r", encoding="utf-8") as f: + return yaml.load(f, Loader=yaml.SafeLoader) + except FileNotFoundError: + pytest.fail(f"Output YAML file not found: {path}") + except Exception as e: + pytest.fail(f"Failed to load or parse YAML file {path}: {e}") + + +def get_all_files_in_tree(node: Dict[str, Any]) -> Set[str]: + """Recursively get all file and directory names from the loaded tree structure.""" + + names: Set[str] = set() + if not isinstance(node, dict) or "name" not in node: + return names + names.add(node["name"]) + if "children" in node and isinstance(node["children"], list): + for child in node["children"]: + if isinstance(child, dict): + names.update(get_all_files_in_tree(child)) + return names + + +def find_node_by_path(tree: Dict[str, Any], path_segments: List[str]) -> Optional[Dict[str, Any]]: + """Find a node in the tree by list of path segments relative to root node.""" + current_node = tree + for segment in path_segments: + if current_node is None or "children" not in current_node or not isinstance(current_node["children"], list): + return None + found_child = None + for child in current_node["children"]: + if isinstance(child, dict) and child.get("name") == segment: + found_child = child + break + if found_child is None: + return None + current_node = found_child + return current_node + + +def make_hashable(obj: Any) -> Hashable: + """Recursively convert dicts and lists to hashable tuples.""" + if isinstance(obj, dict): + return tuple(sorted((k, make_hashable(v)) for k, v in obj.items())) + if isinstance(obj, list): + return tuple(make_hashable(item) for item in obj) + if isinstance(obj, (str, int, float, bool, type(None))): + return obj + try: + hash(obj) + return obj + except TypeError: + return repr(obj) diff --git a/treemapper.spec b/treemapper.spec index 324d922f..ee3310b4 100644 --- a/treemapper.spec +++ b/treemapper.spec @@ -1,41 +1,41 @@ -# -*- mode: python ; coding: utf-8 -*- - -block_cipher = None - -a = Analysis(['src/treemapper/__main__.py'], - pathex=[], - binaries=[], - datas=[], - hiddenimports=[], - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - excludes=[], - win_no_prefer_redirects=False, - win_private_assemblies=False, - cipher=block_cipher, - noarchive=False) -pyz = PYZ(a.pure, a.zipped_data, - cipher=block_cipher) - -exe = EXE( - pyz, - a.scripts, - a.binaries, - a.zipfiles, - a.datas, - [], - name='treemapper', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - upx_exclude=[], - runtime_tmpdir=None, - console=True, - disable_windowed_traceback=False, - argv_emulation=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None, +# -*- mode: python ; coding: utf-8 -*- + +block_cipher = None + +a = Analysis(['src/treemapper/__main__.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False) +pyz = PYZ(a.pure, a.zipped_data, + cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='treemapper', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, ) \ No newline at end of file