diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 06351f8..fb120b8 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -1,7 +1,4 @@ -# This workflow will install Python dependencies, run tests and lint with a variety of Python versions -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - -name: Python package +name: CI on: push: @@ -11,31 +8,43 @@ on: workflow_dispatch: jobs: - build: - - runs-on: ubuntu-latest + test: + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: + os: [ubuntu-latest, windows-latest, macos-latest] python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install flake8 pytest pip install -e .[dev] - - name: Lint with flake8 + + - name: Lint with ruff run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + ruff check . + ruff format --check . + + - name: Type check with pyrefly + run: | + pyrefly check + - name: Test with pytest run: | - pytest + pytest --cov=kicad_lib_manager --cov-report=xml + + - name: Upload coverage to Codecov + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11' + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + fail_ci_if_error: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4d75f52 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,157 @@ +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., v0.3.1)' + required: true + type: string + +permissions: + contents: write + id-token: write + +jobs: + build-and-publish: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build hatch + + - name: Verify version matches tag + run: | + if [[ "${{ github.event_name }}" == "push" ]]; then + TAG_VERSION="${GITHUB_REF#refs/tags/v}" + else + TAG_VERSION="${{ github.event.inputs.version }}" + TAG_VERSION="${TAG_VERSION#v}" + fi + + PACKAGE_VERSION=$(python -c "import kicad_lib_manager; print(kicad_lib_manager.__version__)") + + if [[ "$TAG_VERSION" != "$PACKAGE_VERSION" ]]; then + echo "Version mismatch: tag=$TAG_VERSION, package=$PACKAGE_VERSION" + exit 1 + fi + + echo "VERSION=$TAG_VERSION" >> $GITHUB_ENV + + - name: Run tests + run: | + pip install -e .[dev] + ruff check . + ruff format --check . + pyrefly + pytest + + - name: Build package + run: | + python -m build + + - name: Check package + run: | + pip install twine + twine check dist/* + + - name: Generate release notes + id: release_notes + env: + GH_TOKEN: ${{ github.token }} + run: | + # Get the previous release tag + PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD~1 2>/dev/null || echo "") + + if [ -z "$PREVIOUS_TAG" ]; then + # First release, get all commits + COMMITS=$(git log --oneline --no-merges) + else + # Get commits since last release + COMMITS=$(git log --oneline --no-merges ${PREVIOUS_TAG}..HEAD) + fi + + # Get PR information + PRS=$(gh pr list --state merged --search "merged:>${PREVIOUS_TAG:-1970-01-01}" --json number,title,url --jq '.[] | "- #\(.number): \(.title) (\(.url))"' 2>/dev/null || echo "") + + # Generate release notes + cat > release_notes.md << EOF + ## What's Changed in v${VERSION} + + ### Commits + $(echo "$COMMITS" | sed 's/^/- /') + + EOF + + if [ ! -z "$PRS" ]; then + cat >> release_notes.md << EOF + + ### Pull Requests + $(echo "$PRS") + + EOF + fi + + cat >> release_notes.md << 'EOF' + + ### Installation + + **Using pipx (recommended for CLI tools):** + ```bash + pipx install kilm + ``` + + **Using pip:** + ```bash + pip install kilm + ``` + + **From release assets:** + 1. Download the wheel file from this release + 2. Install with pipx or pip: + ```bash + pipx install kilm-${VERSION}-py3-none-any.whl + # or + pip install kilm-${VERSION}-py3-none-any.whl + ``` + + ### Auto-Update (Coming Soon) + Future versions will include `kilm update` functionality for easy updates. + EOF + + # Store release notes for next step + echo "release_notes<> $GITHUB_OUTPUT + cat release_notes.md >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event_name == 'push' && github.ref_name || format('v{0}', github.event.inputs.version) }} + name: Release ${{ github.event_name == 'push' && github.ref_name || format('v{0}', github.event.inputs.version) }} + body: ${{ steps.release_notes.outputs.release_notes }} + draft: true + prerelease: false + files: | + dist/*.whl + dist/*.tar.gz + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + print-hash: true + verbose: true \ No newline at end of file diff --git a/README.md b/README.md index afcc1c3..45ed792 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Professional command-line tool for managing KiCad libraries across projects and workstations. -**[📚 Official Documentation](https://kilm.aristovnik.me)** +**[Official Documentation](https://kilm.aristovnik.me)** ## Features @@ -41,18 +41,18 @@ kilm setup kilm status ``` -> **📚 [Complete Installation Guide](https://kilm.aristovnik.me/guides/installation/)** - Multiple installation methods, verification steps, and troubleshooting. +> **[Complete Installation Guide](https://kilm.aristovnik.me/guides/installation/)** - Multiple installation methods, verification steps, and troubleshooting. ## Documentation -**[📚 Complete Documentation](https://kilm.aristovnik.me)** +**[Complete Documentation](https://kilm.aristovnik.me)** | Guide | Description | |-------|-------------| -| [🚀 Getting Started](https://kilm.aristovnik.me/guides/getting-started/) | Creator and consumer workflows with Git integration | -| [⚙️ Configuration](https://kilm.aristovnik.me/guides/configuration/) | KiLM and KiCad configuration management | -| [📖 CLI Reference](https://kilm.aristovnik.me/reference/cli/) | Complete command documentation with examples | -| [🛠️ Development](https://kilm.aristovnik.me/community/development/) | Setup guide for contributors and development | +| [Getting Started](https://kilm.aristovnik.me/guides/getting-started/) | Creator and consumer workflows with Git integration | +| [Configuration](https://kilm.aristovnik.me/guides/configuration/) | KiLM and KiCad configuration management | +| [CLI Reference](https://kilm.aristovnik.me/reference/cli/) | Complete command documentation with examples | +| [Development](https://kilm.aristovnik.me/community/development/) | Setup guide for contributors and development | ## License @@ -70,5 +70,5 @@ Contributions are welcome! See our comprehensive guides: git clone https://github.com/barisgit/kilm.git cd kilm pip install -e ".[dev]" -pytest # Run tests +pytest # Run all tests ``` diff --git a/kicad_lib_manager/commands/add_hook/command.py b/kicad_lib_manager/commands/add_hook/command.py index 584875c..ed65d89 100644 --- a/kicad_lib_manager/commands/add_hook/command.py +++ b/kicad_lib_manager/commands/add_hook/command.py @@ -77,7 +77,9 @@ def add_hook(directory, force): else: # Merge with existing content to preserve user logic click.echo("Merging KiLM content with existing hook...") - new_content = merge_hook_content(existing_content, create_kilm_hook_content()) + new_content = merge_hook_content( + existing_content, create_kilm_hook_content() + ) except (OSError, UnicodeDecodeError): click.echo("Warning: Could not read existing hook content, overwriting...") diff --git a/kicad_lib_manager/commands/config/command.py b/kicad_lib_manager/commands/config/command.py index 126a1af..1b59c38 100644 --- a/kicad_lib_manager/commands/config/command.py +++ b/kicad_lib_manager/commands/config/command.py @@ -292,7 +292,9 @@ def set_default(library_name, library_type): # Set as current library if library_path is None: - click.echo(f"Error: Could not find path for library '{library_name}'", err=True) + click.echo( + f"Error: Could not find path for library '{library_name}'", err=True + ) sys.exit(1) config.set_current_library(library_path) click.echo(f"Set {library_type} library '{library_name}' as default.") @@ -350,7 +352,9 @@ def remove(library_name, library_type, force): # Find libraries matching the name and type matching_libraries = [] for lib in all_libraries: - if lib.get("name") == library_name and (library_type == "all" or lib.get("type") == library_type): + if lib.get("name") == library_name and ( + library_type == "all" or lib.get("type") == library_type + ): matching_libraries.append(lib) if not matching_libraries: diff --git a/kicad_lib_manager/commands/setup/command.py b/kicad_lib_manager/commands/setup/command.py index 7684ee2..f6b0715 100644 --- a/kicad_lib_manager/commands/setup/command.py +++ b/kicad_lib_manager/commands/setup/command.py @@ -478,7 +478,6 @@ def setup( # Also list existing libraries to pin them all try: - existing_symbols, existing_footprints = list_libraries(kicad_lib_dir) symbol_libs = existing_symbols footprint_libs = existing_footprints diff --git a/kicad_lib_manager/commands/status/command.py b/kicad_lib_manager/commands/status/command.py index 6a2a4e8..d0b001f 100644 --- a/kicad_lib_manager/commands/status/command.py +++ b/kicad_lib_manager/commands/status/command.py @@ -26,7 +26,11 @@ def status(): config_data = yaml.safe_load(f) # Show libraries - if config_data and "libraries" in config_data and config_data["libraries"]: + if ( + config_data + and "libraries" in config_data + and config_data["libraries"] + ): click.echo(" Configured Libraries:") # Group by type @@ -48,7 +52,8 @@ def status(): path = lib.get("path", "unknown") current = ( " (current)" - if config_data and config_data.get("current_library") == path + if config_data + and config_data.get("current_library") == path else "" ) click.echo(f" - {name}: {path}{current}") @@ -67,7 +72,8 @@ def status(): path = lib.get("path", "unknown") current = ( " (current)" - if config_data and config_data.get("current_library") == path + if config_data + and config_data.get("current_library") == path else "" ) click.echo(f" - {name}: {path}{current}") @@ -83,7 +89,8 @@ def status(): # Show current library if ( - config_data and "current_library" in config_data + config_data + and "current_library" in config_data and config_data["current_library"] ): click.echo( diff --git a/kicad_lib_manager/commands/template/command.py b/kicad_lib_manager/commands/template/command.py index 1b667eb..d6bf8bb 100644 --- a/kicad_lib_manager/commands/template/command.py +++ b/kicad_lib_manager/commands/template/command.py @@ -817,7 +817,12 @@ def make( if not git_path.endswith("/"): git_path += "/" - if gitignore_spec and gitignore_spec.match_file(git_path) or additional_spec and additional_spec.match_file(git_path): + if ( + gitignore_spec + and gitignore_spec.match_file(git_path) + or additional_spec + and additional_spec.match_file(git_path) + ): dirs_to_remove.append(d) excluded_files.append(f"{rel_path}/") diff --git a/kicad_lib_manager/commands/unpin/command.py b/kicad_lib_manager/commands/unpin/command.py index de8179f..0db172c 100644 --- a/kicad_lib_manager/commands/unpin/command.py +++ b/kicad_lib_manager/commands/unpin/command.py @@ -50,7 +50,9 @@ def unpin(symbols, footprints, all, dry_run, max_backups, verbose): """Unpin libraries in KiCad""" # Enforce mutual exclusivity of --all with --symbols/--footprints if all and (symbols or footprints): - raise click.UsageError("'--all' cannot be used with '--symbols' or '--footprints'") + raise click.UsageError( + "'--all' cannot be used with '--symbols' or '--footprints'" + ) # Find KiCad configuration try: diff --git a/kicad_lib_manager/commands/update/command.py b/kicad_lib_manager/commands/update/command.py index f6a5705..2a6a47f 100644 --- a/kicad_lib_manager/commands/update/command.py +++ b/kicad_lib_manager/commands/update/command.py @@ -175,6 +175,7 @@ def update(dry_run, verbose, auto_setup): ) click.echo("Use 'kilm status' to check your current configuration.") + # TODO: Should be in services or utils def check_for_library_changes(git_output, lib_path): """ @@ -202,23 +203,35 @@ def check_for_library_changes(git_output, lib_path): templates_path = lib_path / "templates" # Look for symbol libraries (.kicad_sym files) - if symbols_path.exists() and symbols_path.is_dir() and any( - f.name.endswith(".kicad_sym") for f in symbols_path.glob("**/*.kicad_sym") + if ( + symbols_path.exists() + and symbols_path.is_dir() + and any( + f.name.endswith(".kicad_sym") for f in symbols_path.glob("**/*.kicad_sym") + ) ): changes.append("symbols") # Look for footprint libraries (.pretty directories) - if footprints_path.exists() and footprints_path.is_dir() and any( - f.is_dir() and f.name.endswith(".pretty") - for f in footprints_path.glob("**/*.pretty") + if ( + footprints_path.exists() + and footprints_path.is_dir() + and any( + f.is_dir() and f.name.endswith(".pretty") + for f in footprints_path.glob("**/*.pretty") + ) ): changes.append("footprints") # Look for project templates (directories with metadata.yaml) - if templates_path.exists() and templates_path.is_dir() and any( - (f / "metadata.yaml").exists() - for f in templates_path.glob("*") - if f.is_dir() + if ( + templates_path.exists() + and templates_path.is_dir() + and any( + (f / "metadata.yaml").exists() + for f in templates_path.glob("*") + if f.is_dir() + ) ): changes.append("templates") diff --git a/kicad_lib_manager/config.py b/kicad_lib_manager/config.py index d879464..9c0c62e 100644 --- a/kicad_lib_manager/config.py +++ b/kicad_lib_manager/config.py @@ -18,6 +18,7 @@ class LibraryDict(TypedDict): """Type definition for library configuration.""" + name: str path: str type: str @@ -62,7 +63,9 @@ def _get_config_file(self) -> Path: config_dir.mkdir(parents=True, exist_ok=True) return config_dir / CONFIG_FILE_NAME - def get(self, key: str, default: Optional[ConfigValue] = None) -> Optional[ConfigValue]: + def get( + self, key: str, default: Optional[ConfigValue] = None + ) -> Optional[ConfigValue]: """Get a configuration value""" return self._config.get(key, default) @@ -122,9 +125,7 @@ def remove_library(self, name: str, library_type: Optional[str] = None) -> bool: if not (lib["name"] == name and lib["type"] == library_type) ] else: - filtered_libraries = [ - lib for lib in libraries if lib["name"] != name - ] + filtered_libraries = [lib for lib in libraries if lib["name"] != name] self._config["libraries"] = filtered_libraries removed = len(filtered_libraries) < original_count @@ -263,21 +264,19 @@ def _normalize_libraries_field(self) -> None: # If libraries is not a list, reset to empty list if not isinstance(libraries, list): - click.echo(f"Warning: libraries field in config was {type(libraries).__name__}, resetting to empty list", err=True) + click.echo( + f"Warning: libraries field in config was {type(libraries).__name__}, resetting to empty list", + err=True, + ) self._config["libraries"] = [] return # Validate and clean up each library entry normalized_libraries: List[LibraryDict] = [] for lib in libraries: - if isinstance(lib, dict) and all(key in lib for key in ["name", "path", "type"]): - # Ensure all values are strings - normalized_lib = _make_library_dict( - name=str(lib["name"]), - path=str(lib["path"]), - type_=str(lib["type"]), - ) - normalized_libraries.append(normalized_lib) + validated_lib = _validate_library_entry(lib) + if validated_lib is not None: + normalized_libraries.append(validated_lib) else: click.echo(f"Warning: Skipping invalid library entry: {lib}", err=True) @@ -294,7 +293,10 @@ def _get_normalized_libraries(self) -> List[LibraryDict]: # If libraries is not a list, reset to empty list if not isinstance(libraries_raw, list): - click.echo(f"Warning: libraries field was {type(libraries_raw).__name__}, resetting to empty list", err=True) + click.echo( + f"Warning: libraries field was {type(libraries_raw).__name__}, resetting to empty list", + err=True, + ) self._config["libraries"] = [] return [] @@ -303,14 +305,9 @@ def _get_normalized_libraries(self) -> List[LibraryDict]: needs_save = False for lib in libraries_raw: - if isinstance(lib, dict) and all(key in lib for key in ["name", "path", "type"]): - # Ensure all values are strings - normalized_lib = _make_library_dict( - name=str(lib["name"]), - path=str(lib["path"]), - type_=str(lib["type"]), - ) - normalized_libraries.append(normalized_lib) + validated_lib = _validate_library_entry(lib) + if validated_lib is not None: + normalized_libraries.append(validated_lib) else: click.echo(f"Warning: Skipping invalid library entry: {lib}", err=True) needs_save = True @@ -325,4 +322,17 @@ def _get_normalized_libraries(self) -> List[LibraryDict]: def _make_library_dict(name: str, path: str, type_: str) -> LibraryDict: """Typed constructor for `LibraryDict` to satisfy type checker.""" - return cast("LibraryDict", {"name": name, "path": path, "type": type_}) + return LibraryDict(name=name, path=path, type=type_) + + +def _validate_library_entry( + lib: Union[Dict[str, str], LibraryDict], +) -> Optional[LibraryDict]: + """Validate and normalize a library entry.""" + if isinstance(lib, dict) and all(key in lib for key in ["name", "path", "type"]): + return _make_library_dict( + name=str(lib["name"]), + path=str(lib["path"]), + type_=str(lib["type"]), + ) + return None diff --git a/kicad_lib_manager/utils/file_ops.py b/kicad_lib_manager/utils/file_ops.py index a2d9902..87ec2d5 100644 --- a/kicad_lib_manager/utils/file_ops.py +++ b/kicad_lib_manager/utils/file_ops.py @@ -7,7 +7,9 @@ from typing import List, Optional, Tuple -def read_file_with_encoding(file_path: Path, encodings: Optional[List[str]] = None) -> str: +def read_file_with_encoding( + file_path: Path, encodings: Optional[List[str]] = None +) -> str: """ Read a file trying multiple encodings until successful diff --git a/kicad_lib_manager/utils/git_utils.py b/kicad_lib_manager/utils/git_utils.py index 3ddb80f..c56df59 100644 --- a/kicad_lib_manager/utils/git_utils.py +++ b/kicad_lib_manager/utils/git_utils.py @@ -3,6 +3,7 @@ Handles Git hooks directory detection and safe hook management. """ +import os import subprocess from datetime import datetime from pathlib import Path @@ -32,7 +33,9 @@ def get_git_hooks_directory(repo_path: Path) -> Path: try: rp = subprocess.run( ["git", "-C", str(repo_path), "rev-parse", "--git-path", "hooks"], - capture_output=True, text=True, check=True + capture_output=True, + text=True, + check=True, ) hooks_dir = Path(rp.stdout.strip()) if not hooks_dir.is_absolute(): @@ -44,7 +47,9 @@ def get_git_hooks_directory(repo_path: Path) -> Path: try: cd = subprocess.run( ["git", "-C", str(repo_path), "rev-parse", "--git-common-dir"], - capture_output=True, text=True, check=True + capture_output=True, + text=True, + check=True, ) common_dir = Path(cd.stdout.strip()) if not common_dir.is_absolute(): @@ -72,8 +77,10 @@ def backup_existing_hook(hook_path: Path) -> Path: # Copy the file content backup_path.write_text(hook_path.read_text(encoding="utf-8")) - # Preserve executable permissions - if hook_path.stat().st_mode & 0o111: # Check if executable + # Preserve executable permissions (Unix-like systems only) + if ( + os.name != "nt" and hook_path.stat().st_mode & 0o111 + ): # Not Windows and executable backup_path.chmod(0o755) return backup_path @@ -93,7 +100,7 @@ def merge_hook_content(existing_content: str, kilm_content: str) -> str: # Check if KiLM content is already present if "KiLM-managed section" in existing_content: # Already has KiLM content, replace the section - lines = existing_content.split('\n') + lines = existing_content.split("\n") start_marker = "# BEGIN KiLM-managed section" end_marker = "# END KiLM-managed section" @@ -109,8 +116,8 @@ def merge_hook_content(existing_content: str, kilm_content: str) -> str: if start_idx is not None and end_idx is not None: # Replace existing KiLM section - new_lines = lines[:start_idx] + [kilm_content] + lines[end_idx + 1:] - return '\n'.join(new_lines) + new_lines = lines[:start_idx] + [kilm_content] + lines[end_idx + 1 :] + return "\n".join(new_lines) # Add KiLM content at the end with clear markers return f"{existing_content.rstrip()}\n\n{kilm_content}" diff --git a/kicad_lib_manager/utils/metadata.py b/kicad_lib_manager/utils/metadata.py index a30220d..a0e5e4a 100644 --- a/kicad_lib_manager/utils/metadata.py +++ b/kicad_lib_manager/utils/metadata.py @@ -12,7 +12,9 @@ from ..constants import CLOUD_METADATA_FILE, GITHUB_METADATA_FILE -def read_github_metadata(directory: Path) -> Optional[Dict[str, Union[str, List, Dict]]]: +def read_github_metadata( + directory: Path, +) -> Optional[Dict[str, Union[str, List, Dict]]]: """ Read metadata from a GitHub library directory. diff --git a/kicad_lib_manager/utils/template.py b/kicad_lib_manager/utils/template.py index bb7d323..6e32a32 100644 --- a/kicad_lib_manager/utils/template.py +++ b/kicad_lib_manager/utils/template.py @@ -276,7 +276,8 @@ def create_template_metadata( metadata["extends"] = extends if dependencies: - metadata["dependencies"] = {"recommended": dependencies} + deps_dict: Dict[str, Any] = {"recommended": dependencies} + metadata["dependencies"] = deps_dict return metadata @@ -409,9 +410,9 @@ def create_template_structure( MAX_PATH_LENGTH = 512 # Track main KiCad files - main_project_file = None - main_schematic_file = None - main_pcb_file = None + main_project_file: Optional[str] = None + main_schematic_file: Optional[str] = None + main_pcb_file: Optional[str] = None # First scan to find main KiCad files click.echo("Scanning for main KiCad files...") @@ -430,31 +431,32 @@ def create_template_structure( continue # Look for KiCad files in the root directory - for file in files: - rel_path = str(Path(rel_root) / file) - git_path = rel_path.replace(os.sep, "/") + for file_name in files: + file_path = Path(rel_root) / file_name + rel_path_str = str(file_path) + git_path = rel_path_str.replace(os.sep, "/") # Skip gitignored files - if gitignore_spec and gitignore_spec.match_file(git_path): + if gitignore_spec is not None and gitignore_spec.match_file(git_path): continue # Look for main files in the root directory or top-level folders if rel_root == "." or len(Path(rel_root).parts) <= 1: - file_lower = file.lower() + file_lower = file_name.lower() if file_lower.endswith(KICAD_PROJECT_EXT) and not main_project_file: - main_project_file = str(Path(root) / file) - click.echo(f"Found main project file: {file}") + main_project_file = str(Path(root) / file_name) + click.echo(f"Found main project file: {file_name}") elif ( file_lower.endswith(KICAD_SCHEMATIC_EXT) and not main_schematic_file ): - main_schematic_file = str(Path(root) / file) - click.echo(f"Found main schematic file: {file}") + main_schematic_file = str(Path(root) / file_name) + click.echo(f"Found main schematic file: {file_name}") elif file_lower.endswith(KICAD_PCB_EXT) and not main_pcb_file: - main_pcb_file = str(Path(root) / file) - click.echo(f"Found main PCB file: {file}") + main_pcb_file = str(Path(root) / file_name) + click.echo(f"Found main PCB file: {file_name}") # Copy files from source to template for root, dirs, files in os.walk(source_directory): @@ -476,7 +478,7 @@ def create_template_structure( # Ensure proper path format for gitignore matching # (pathspec expects paths with forward slashes and trailing slash for directories) - git_path = rel_path.replace(os.sep, "/") + git_path = str(rel_path).replace(os.sep, "/") if not git_path.endswith("/"): git_path += "/" @@ -516,7 +518,7 @@ def create_template_structure( rel_path = Path(rel_root) / file # Ensure proper path format for gitignore matching - git_path = rel_path.replace(os.sep, "/") + git_path = str(rel_path).replace(os.sep, "/") # Skip gitignored files and additional excluded files if gitignore_spec and gitignore_spec.match_file(git_path): @@ -698,9 +700,7 @@ def process_kicad_project_file( shutil.copy2(source_file, target_file) -def process_kicad_schematic_file( - source_file: Path, target_file: Path -) -> None: +def process_kicad_schematic_file(source_file: Path, target_file: Path) -> None: """ Process a KiCad schematic file (.kicad_sch). @@ -737,9 +737,7 @@ def process_kicad_schematic_file( shutil.copy2(source_file, target_file) -def process_kicad_pcb_file( - source_file: Path, target_file: Path -) -> None: +def process_kicad_pcb_file(source_file: Path, target_file: Path) -> None: """ Process a KiCad PCB file (.kicad_pcb). diff --git a/tests/test_config_commands.py b/tests/test_config_commands.py index 4ff2c7d..7a93100 100644 --- a/tests/test_config_commands.py +++ b/tests/test_config_commands.py @@ -2,16 +2,18 @@ Tests for KiCad Library Manager config commands. """ +from typing import List + import pytest from click.testing import CliRunner from kicad_lib_manager.cli import main -from kicad_lib_manager.config import Config +from kicad_lib_manager.config import Config, LibraryDict # Sample test data -TEST_LIBRARIES = [ - {"name": "test-github-lib", "path": "/path/to/github/library", "type": "github"}, - {"name": "test-cloud-lib", "path": "/path/to/cloud/library", "type": "cloud"}, +TEST_LIBRARIES: List[LibraryDict] = [ + LibraryDict(name="test-github-lib", path="/path/to/github/library", type="github"), + LibraryDict(name="test-cloud-lib", path="/path/to/cloud/library", type="cloud"), ] @@ -41,7 +43,9 @@ def mock_load_config(): @pytest.fixture def mock_config_class(monkeypatch, mock_config): """Mock the Config class to return our mock config.""" - monkeypatch.setattr("kicad_lib_manager.commands.config.command.Config", lambda: mock_config) + monkeypatch.setattr( + "kicad_lib_manager.commands.config.command.Config", lambda: mock_config + ) return mock_config diff --git a/tests/test_git_utils.py b/tests/test_git_utils.py index 02ed0e3..fe949d1 100644 --- a/tests/test_git_utils.py +++ b/tests/test_git_utils.py @@ -2,6 +2,7 @@ Tests for Git utility functions. """ +import os from unittest.mock import Mock, patch import pytest @@ -41,7 +42,10 @@ def test_backup_existing_hook(self, tmp_path): assert backup_path != hook_file assert backup_path.read_text() == hook_content assert backup_path.name.startswith("post-merge.backup.") - assert backup_path.stat().st_mode & 0o111 # Check executable bit + + # Check executable bit only on Unix-like systems + if os.name != "nt": # Not Windows + assert backup_path.stat().st_mode & 0o111 # Check executable bit def test_merge_hook_content_new_content(self): """Test merging when no KiLM content exists.""" @@ -75,7 +79,7 @@ def test_merge_hook_content_replace_existing(self): assert "old kilm content" not in result assert "kilm update" in result - @patch('subprocess.run') + @patch("subprocess.run") def test_get_git_hooks_directory_standard(self, mock_run, tmp_path): """Test standard hooks directory detection.""" # Mock the first call to succeed with hooks path @@ -90,7 +94,7 @@ def test_get_git_hooks_directory_standard(self, mock_run, tmp_path): assert hooks_dir == repo_path / ".git" / "hooks" mock_run.assert_called_once() - @patch('subprocess.run') + @patch("subprocess.run") def test_get_git_hooks_directory_custom_path(self, mock_run, tmp_path): """Test custom hooks directory detection.""" custom_hooks = tmp_path / "custom" / "hooks" @@ -107,7 +111,7 @@ def test_get_git_hooks_directory_custom_path(self, mock_run, tmp_path): assert hooks_dir == custom_hooks mock_run.assert_called_once() - @patch('subprocess.run') + @patch("subprocess.run") def test_get_git_hooks_directory_relative_path(self, mock_run, tmp_path): """Test relative custom hooks path handling.""" mock_run.return_value = Mock(returncode=0, stdout="custom/hooks") @@ -122,7 +126,7 @@ def test_get_git_hooks_directory_relative_path(self, mock_run, tmp_path): assert hooks_dir == repo_path / "custom" / "hooks" mock_run.assert_called_once() - @patch('subprocess.run') + @patch("subprocess.run") def test_get_git_hooks_directory_worktree(self, mock_run, tmp_path): """Test Git worktree hooks directory detection.""" # Mock the Git command to return the worktree hooks path @@ -144,5 +148,3 @@ def test_get_git_hooks_directory_not_repo(self, tmp_path): """Test error when directory is not a Git repository.""" with pytest.raises(RuntimeError, match="Not a Git repository"): get_git_hooks_directory(tmp_path) - - diff --git a/tests/test_unpin_command.py b/tests/test_unpin_command.py index 57a0dc9..bbf8d95 100644 --- a/tests/test_unpin_command.py +++ b/tests/test_unpin_command.py @@ -19,66 +19,82 @@ def setup_method(self): def test_mutual_exclusivity_all_with_symbols(self): """Test that --all cannot be used with --symbols.""" - result = self.runner.invoke(unpin, ['--all', '--symbols', 'lib1']) + result = self.runner.invoke(unpin, ["--all", "--symbols", "lib1"]) assert result.exit_code == 2 # Click usage error exit code - assert "'--all' cannot be used with '--symbols' or '--footprints'" in result.output + assert ( + "'--all' cannot be used with '--symbols' or '--footprints'" in result.output + ) def test_mutual_exclusivity_all_with_footprints(self): """Test that --all cannot be used with --footprints.""" - result = self.runner.invoke(unpin, ['--all', '--footprints', 'lib1']) + result = self.runner.invoke(unpin, ["--all", "--footprints", "lib1"]) assert result.exit_code == 2 # Click usage error exit code - assert "'--all' cannot be used with '--symbols' or '--footprints'" in result.output + assert ( + "'--all' cannot be used with '--symbols' or '--footprints'" in result.output + ) def test_mutual_exclusivity_all_with_both(self): """Test that --all cannot be used with both --symbols and --footprints.""" - result = self.runner.invoke(unpin, ['--all', '--symbols', 'lib1', '--footprints', 'lib2']) + result = self.runner.invoke( + unpin, ["--all", "--symbols", "lib1", "--footprints", "lib2"] + ) assert result.exit_code == 2 # Click usage error exit code - assert "'--all' cannot be used with '--symbols' or '--footprints'" in result.output + assert ( + "'--all' cannot be used with '--symbols' or '--footprints'" in result.output + ) def test_mutual_exclusivity_all_only(self): """Test that --all can be used without --symbols or --footprints.""" - with patch('kicad_lib_manager.commands.unpin.command.find_kicad_config') as mock_find_config: + with patch( + "kicad_lib_manager.commands.unpin.command.find_kicad_config" + ) as mock_find_config: mock_find_config.return_value = Path("/tmp/kicad") - with patch('pathlib.Path.exists') as mock_exists: + with patch("pathlib.Path.exists") as mock_exists: mock_exists.return_value = True - with patch('builtins.open') as mock_open: - mock_open.return_value.__enter__.return_value.read.return_value = '{"session": {"pinned_symbol_libs": [], "pinned_fp_libs": []}}' + with patch("builtins.open") as mock_open: + mock_open.return_value.__enter__.return_value.read.return_value = ( + '{"session": {"pinned_symbol_libs": [], "pinned_fp_libs": []}}' + ) # Should not raise an error - result = self.runner.invoke(unpin, ['--all']) + result = self.runner.invoke(unpin, ["--all"]) # Exit code 0 means success, or 1 if no libraries found (which is expected) assert result.exit_code in [0, 1] def test_mutual_exclusivity_symbols_only(self): """Test that --symbols can be used without --all.""" - with patch('kicad_lib_manager.commands.unpin.command.find_kicad_config') as mock_find_config: + with patch( + "kicad_lib_manager.commands.unpin.command.find_kicad_config" + ) as mock_find_config: mock_find_config.return_value = Path("/tmp/kicad") - with patch('pathlib.Path.exists') as mock_exists: + with patch("pathlib.Path.exists") as mock_exists: mock_exists.return_value = True - with patch('builtins.open') as mock_open: + with patch("builtins.open") as mock_open: mock_open.return_value.__enter__.return_value.read.return_value = '{"session": {"pinned_symbol_libs": ["lib1"], "pinned_fp_libs": []}}' # Should not raise an error - result = self.runner.invoke(unpin, ['--symbols', 'lib1']) + result = self.runner.invoke(unpin, ["--symbols", "lib1"]) # Exit code 0 means success, or 1 if no libraries found (which is expected) assert result.exit_code in [0, 1] def test_mutual_exclusivity_footprints_only(self): """Test that --footprints can be used without --all.""" - with patch('kicad_lib_manager.commands.unpin.command.find_kicad_config') as mock_find_config: + with patch( + "kicad_lib_manager.commands.unpin.command.find_kicad_config" + ) as mock_find_config: mock_find_config.return_value = Path("/tmp/kicad") - with patch('pathlib.Path.exists') as mock_exists: + with patch("pathlib.Path.exists") as mock_exists: mock_exists.return_value = True - with patch('builtins.open') as mock_open: + with patch("builtins.open") as mock_open: mock_open.return_value.__enter__.return_value.read.return_value = '{"session": {"pinned_symbol_libs": [], "pinned_fp_libs": ["lib1"]}}' # Should not raise an error - result = self.runner.invoke(unpin, ['--footprints', 'lib1']) + result = self.runner.invoke(unpin, ["--footprints", "lib1"]) # Exit code 0 means success, or 1 if no libraries found (which is expected) assert result.exit_code in [0, 1] diff --git a/tests/test_update_command.py b/tests/test_update_command.py index 78fd196..3a3f055 100644 --- a/tests/test_update_command.py +++ b/tests/test_update_command.py @@ -23,7 +23,9 @@ def mock_config(monkeypatch): config_mock = MagicMock() config_mock.get_libraries.return_value = TEST_LIBRARIES - monkeypatch.setattr("kicad_lib_manager.commands.update.command.Config", lambda: config_mock) + monkeypatch.setattr( + "kicad_lib_manager.commands.update.command.Config", lambda: config_mock + ) return config_mock @@ -169,7 +171,9 @@ def test_update_no_libraries(monkeypatch): """Test update when no libraries are configured.""" config_mock = MagicMock() config_mock.get_libraries.return_value = [] - monkeypatch.setattr("kicad_lib_manager.commands.update.command.Config", lambda: config_mock) + monkeypatch.setattr( + "kicad_lib_manager.commands.update.command.Config", lambda: config_mock + ) runner = CliRunner() result = runner.invoke(main, ["update"])